yfinance-rs 0.8.0

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

use serde::Deserialize;
use url::Url;

use crate::{
    YfClient, YfError,
    core::{
        client::{CacheMode, RetryConfig},
        conversions::{
            f64_to_decimal_safely, f64_to_price_with_currency, i64_to_datetime, string_to_exchange,
        },
        net,
    },
    screener::YahooQuoteType,
};
use paft::money::Currency;

use super::model::{OptionChain, OptionContract};
use chrono::{NaiveDate, TimeZone, Utc};
use paft::domain::{AssetKind, Instrument};
use paft::market::options::{OptionContractKey, OptionSide};

/* ---------------- Public: expirations + chain ---------------- */

pub async fn expiration_dates(
    client: &YfClient,
    symbol: &str,
    cache_mode: CacheMode,
    retry_override: Option<&RetryConfig>,
) -> Result<Vec<i64>, YfError> {
    let (body, _used_url) =
        fetch_options_raw(client, symbol, None, cache_mode, retry_override).await?;
    let env: OptEnvelope = serde_json::from_str(&body).map_err(YfError::Json)?;

    let first = env
        .option_chain
        .and_then(|oc| oc.result)
        .and_then(|mut v| v.pop())
        .ok_or_else(|| YfError::MissingData("empty options result".into()))?;

    Ok(first.expiration_dates.unwrap_or_default())
}

pub async fn option_chain(
    client: &YfClient,
    symbol: &str,
    date: Option<i64>,
    cache_mode: CacheMode,
    retry_override: Option<&RetryConfig>,
) -> Result<OptionChain, YfError> {
    let (body, used_url) =
        fetch_options_raw(client, symbol, date, cache_mode, retry_override).await?;
    let env: OptEnvelope = serde_json::from_str(&body).map_err(YfError::Json)?;

    let first = env
        .option_chain
        .and_then(|oc| oc.result)
        .and_then(|mut v| v.pop())
        .ok_or_else(|| YfError::MissingData("empty options result".into()))?;

    let currency_from_response = currency_from_result(&first);
    let underlying_from_response = underlying_instrument_from_result(&first);

    let Some(od) = first.options.and_then(|mut v| v.pop()) else {
        return Ok(OptionChain {
            contracts: vec![],
            provider: (),
        });
    };

    let expiration = od.expiration_date.unwrap_or_else(|| {
        if let Some(q) = used_url.query() {
            for kv in q.split('&') {
                if let Some(v) = kv.strip_prefix("date=")
                    && let Ok(ts) = v.parse::<i64>()
                {
                    return ts;
                }
            }
        }
        0
    });

    let (currency, underlying) = if let Some(currency) = currency_from_response {
        (
            currency,
            underlying_instrument(client, symbol, underlying_from_response).await?,
        )
    } else {
        let quote = super::quote::fetch_quote(client, symbol, cache_mode, retry_override).await?;
        let currency = quote
            .price
            .as_ref()
            .map(|m| m.currency().clone())
            .or_else(|| quote.previous_close.as_ref().map(|m| m.currency().clone()))
            .ok_or_else(|| {
                YfError::MissingData("unable to determine currency from options or quote".into())
            })?;
        (
            currency,
            underlying_from_response.unwrap_or(quote.instrument),
        )
    };

    let map_side = |side: Option<Vec<OptContractNode>>,
                    option_side: OptionSide,
                    currency: &Currency|
     -> Vec<OptionContract> {
        side.unwrap_or_default()
            .into_iter()
            .map(|c| {
                let exp_ts = c.expiration.unwrap_or(expiration);
                let exp_dt = i64_to_datetime(exp_ts);
                let exp_date: NaiveDate = exp_dt.date_naive();
                let strike = f64_to_price_with_currency(c.strike.unwrap_or(0.0), currency.clone());
                let key = OptionContractKey::new(underlying.clone(), option_side, strike, exp_date);
                let contract_instrument = c
                    .contract_symbol
                    .as_deref()
                    .and_then(|sym| Instrument::from_symbol(sym, AssetKind::Option).ok());

                OptionContract {
                    key,
                    contract_instrument,
                    price: c
                        .last_price
                        .map(|v| f64_to_price_with_currency(v, currency.clone())),
                    bid: c
                        .bid
                        .map(|v| f64_to_price_with_currency(v, currency.clone())),
                    ask: c
                        .ask
                        .map(|v| f64_to_price_with_currency(v, currency.clone())),
                    volume: c.volume,
                    open_interest: c.open_interest,
                    implied_volatility: c.implied_volatility.map(f64_to_decimal_safely),
                    in_the_money: c.in_the_money,
                    expiration_at: Some(exp_dt),
                    last_trade_at: c
                        .last_trade_date
                        .and_then(|ts| Utc.timestamp_opt(ts, 0).single()),
                    greeks: None,
                    provider: (),
                }
            })
            .collect()
    };

    Ok(OptionChain {
        contracts: map_side(od.calls, OptionSide::Call, &currency)
            .into_iter()
            .chain(map_side(od.puts, OptionSide::Put, &currency))
            .collect(),
        provider: (),
    })
}

async fn underlying_instrument(
    client: &YfClient,
    symbol: &str,
    response_instrument: Option<Instrument>,
) -> Result<Instrument, YfError> {
    if let Some(instrument) = response_instrument {
        client
            .store_instrument(symbol.to_string(), instrument.clone())
            .await;
        client
            .store_instrument(instrument.symbol.as_str().to_string(), instrument.clone())
            .await;
        return Ok(instrument);
    }

    if let Some(instrument) = client.cached_instrument(symbol).await {
        return Ok(instrument);
    }

    Instrument::from_symbol(symbol, AssetKind::Equity)
        .map_err(|err| YfError::InvalidParams(format!("invalid option underlying symbol: {err}")))
}

fn underlying_instrument_from_result(node: &OptResultNode) -> Option<Instrument> {
    let quote = node.quote.as_ref();
    let symbol = node
        .underlying_symbol
        .as_deref()
        .or_else(|| quote.and_then(|quote| quote.symbol.as_deref()))?;
    let kind = quote
        .and_then(|quote| quote.quote_type.as_deref())
        .map_or(AssetKind::Equity, quote_type_to_asset_kind);
    let exchange = quote.and_then(OptQuoteNode::exchange);

    match exchange {
        Some(exchange) => Instrument::from_symbol_and_exchange(symbol, exchange, kind),
        None => Instrument::from_symbol(symbol, kind),
    }
    .ok()
}

fn quote_type_to_asset_kind(value: &str) -> AssetKind {
    YahooQuoteType::parse(value)
        .map(YahooQuoteType::asset_kind)
        .or_else(|| value.parse::<AssetKind>().ok())
        .unwrap_or(AssetKind::Equity)
}

/* ---------------- Internal: raw fetch with auth fallback ---------------- */

async fn fetch_options_raw(
    client: &YfClient,
    symbol: &str,
    date: Option<i64>,
    cache_mode: CacheMode,
    retry_override: Option<&RetryConfig>,
) -> Result<(String, Url), YfError> {
    let http = client.http().clone();
    let base = client.base_options_v7();

    let mut url = base.join(symbol)?;
    {
        let mut qp = url.query_pairs_mut();
        if let Some(d) = date {
            qp.append_pair("date", &d.to_string());
        }
    }

    if cache_mode == CacheMode::Use
        && let Some(body) = client.cache_get(&url).await
    {
        return Ok((body, url));
    }

    let req = http.get(url.clone()).header("accept", "application/json");
    let mut resp = client.send_with_retry(req, retry_override).await?;

    if resp.status().is_success() {
        let fixture_key = date.map_or_else(|| symbol.to_string(), |d| format!("{symbol}_{d}"));
        let body = net::get_text(resp, "options_v7", &fixture_key, "json").await?;
        if cache_mode != CacheMode::Bypass {
            client.cache_put(&url, &body, None).await;
        }
        return Ok((body, url));
    }

    let code = resp.status().as_u16();
    if code != 401 && code != 403 {
        let url_s = url.to_string();
        return Err(match code {
            404 => YfError::NotFound { url: url_s },
            429 => YfError::RateLimited { url: url_s },
            500..=599 => YfError::ServerError {
                status: code,
                url: url_s,
            },
            _ => YfError::Status {
                status: code,
                url: url_s,
            },
        });
    }

    client.ensure_credentials().await?;
    let crumb = client.crumb().await.ok_or_else(|| YfError::Status {
        status: code,
        url: url.to_string(),
    })?;

    let mut url2 = base.join(symbol)?;
    {
        let mut qp = url2.query_pairs_mut();
        if let Some(d) = date {
            qp.append_pair("date", &d.to_string());
        }
        qp.append_pair("crumb", &crumb);
    }

    let req2 = http.get(url2.clone()).header("accept", "application/json");
    resp = client.send_with_retry(req2, retry_override).await?;

    if !resp.status().is_success() {
        let code = resp.status().as_u16();
        let url_s = url2.to_string();
        return Err(match code {
            404 => YfError::NotFound { url: url_s },
            429 => YfError::RateLimited { url: url_s },
            500..=599 => YfError::ServerError {
                status: code,
                url: url_s,
            },
            _ => YfError::Status {
                status: code,
                url: url_s,
            },
        });
    }

    let fixture_key = date.map_or_else(|| symbol.to_string(), |d| format!("{symbol}_{d}"));
    let body = net::get_text(resp, "options_v7", &fixture_key, "json").await?;
    if cache_mode != CacheMode::Bypass {
        client.cache_put(&url2, &body, None).await;
    }
    Ok((body, url2))
}

/* ---------------- Minimal serde mapping for v7 options ---------------- */

#[derive(Deserialize)]
struct OptEnvelope {
    #[serde(rename = "optionChain")]
    option_chain: Option<OptChainNode>,
}

#[derive(Deserialize)]
struct OptChainNode {
    result: Option<Vec<OptResultNode>>,
    #[allow(dead_code)]
    error: Option<serde_json::Value>,
}

#[derive(Deserialize)]
struct OptResultNode {
    #[serde(rename = "underlyingSymbol")]
    underlying_symbol: Option<String>,
    #[serde(rename = "expirationDates")]
    expiration_dates: Option<Vec<i64>>,
    quote: Option<OptQuoteNode>,
    options: Option<Vec<OptByDateNode>>,
}

#[derive(Deserialize)]
struct OptQuoteNode {
    symbol: Option<String>,
    #[serde(rename = "quoteType")]
    quote_type: Option<String>,
    #[serde(rename = "fullExchangeName")]
    full_exchange_name: Option<String>,
    exchange: Option<String>,
    market: Option<String>,
    #[serde(rename = "marketCapFigureExchange")]
    market_cap_figure_exchange: Option<String>,
    currency: Option<String>,
}

impl OptQuoteNode {
    fn exchange(&self) -> Option<paft::domain::Exchange> {
        string_to_exchange(
            self.full_exchange_name
                .clone()
                .or_else(|| self.exchange.clone())
                .or_else(|| self.market.clone())
                .or_else(|| self.market_cap_figure_exchange.clone()),
        )
    }
}

#[derive(Deserialize)]
struct OptByDateNode {
    #[serde(rename = "expirationDate")]
    expiration_date: Option<i64>,
    calls: Option<Vec<OptContractNode>>,
    puts: Option<Vec<OptContractNode>>,
}

#[derive(Deserialize)]
struct OptContractNode {
    #[serde(rename = "contractSymbol")]
    contract_symbol: Option<String>,
    #[serde(rename = "expiration")]
    expiration: Option<i64>,
    #[serde(rename = "lastTradeDate")]
    last_trade_date: Option<i64>,
    strike: Option<f64>,
    #[serde(rename = "lastPrice")]
    last_price: Option<f64>,
    bid: Option<f64>,
    ask: Option<f64>,
    volume: Option<u64>,
    #[serde(rename = "openInterest")]
    open_interest: Option<u64>,
    #[serde(rename = "impliedVolatility")]
    implied_volatility: Option<f64>,
    #[serde(rename = "inTheMoney")]
    in_the_money: Option<bool>,
}

fn currency_from_result(node: &OptResultNode) -> Option<Currency> {
    node.quote
        .as_ref()
        .and_then(|q| q.currency.as_deref())
        .and_then(|code| Currency::from_str(code).ok())
}