yf-options 0.2.1

A fast, reliable command-line tool for downloading options chain data from Yahoo Finance. Features include Black-Scholes Greeks calculation (Delta, Gamma, Theta, Vega, Rho), filtering by expiration date, strike range, and ITM/OTM status. Supports multiple symbols with combined output, JSON/CSV export formats, and built-in rate limiting. Ideal for options analysis, volatility screening, and quantitative trading workflows.
Documentation
//! Yahoo Finance options client using yf-common shared infrastructure.
//!
//! Uses CrumbAuth from yf-common for cookie/crumb authentication,
//! and YfRateLimiter for rate limiting. The reqwest Client is built
//! locally with cookie jar support (required for auth flow).

use crate::error::{Result, YfOptionsError};
use crate::models::ApiResponse;
use std::sync::Arc;
use tracing::{debug, warn};
use yf_common::auth::{AuthProvider, CrumbAuth};
use yf_common::rate_limit::{wait_for_permit, RateLimitConfig, YfRateLimiter};
use yf_common::retry::RetryConfig;

const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const OPTIONS_URL: &str = "https://query1.finance.yahoo.com/v7/finance/options";

/// Yahoo Finance options client backed by yf-common auth and rate limiting.
pub struct OptionsClient {
    /// Raw reqwest client with cookie jar for auth support
    client: reqwest::Client,
    /// Shared authentication provider (cookie/crumb flow)
    auth: CrumbAuth,
    /// Rate limiter from yf-common
    rate_limiter: Arc<YfRateLimiter>,
    /// Retry configuration from yf-common
    #[allow(dead_code)] // Available for future retry integration
    retry_config: RetryConfig,
}

impl OptionsClient {
    /// Create a new options client with rate limiting.
    ///
    /// The client is NOT authenticated yet — call `authenticate()` before fetching data.
    pub fn new(requests_per_minute: u32) -> Result<Self> {
        let auth = CrumbAuth::new();
        let cookie_jar = auth.cookie_jar();

        // Build reqwest client with cookie jar (required for crumb auth flow)
        let client = reqwest::Client::builder()
            .user_agent(USER_AGENT)
            .timeout(std::time::Duration::from_secs(30))
            .cookie_provider(cookie_jar)
            .build()?;

        let rate_limit_config = RateLimitConfig::new(requests_per_minute);
        let rate_limiter = rate_limit_config.build_limiter();
        let retry_config = RetryConfig::default();

        Ok(Self {
            client,
            auth,
            rate_limiter,
            retry_config,
        })
    }

    /// Initialize session by fetching cookies and crumb via yf-common CrumbAuth.
    pub async fn authenticate(&self) -> Result<()> {
        debug!("Authenticating with Yahoo Finance via yf-common CrumbAuth...");
        self.auth
            .authenticate(&self.client)
            .await
            .map_err(YfOptionsError::Common)?;
        debug!("Authentication successful");
        Ok(())
    }

    /// Re-authenticate (clear credentials and authenticate again).
    async fn reauthenticate(&self) -> Result<()> {
        self.auth.clear_credentials();
        self.authenticate().await
    }

    /// Check if we have valid credentials, authenticate if not.
    async fn ensure_authenticated(&self) -> Result<()> {
        if !self.auth.is_authenticated() {
            self.authenticate().await?;
        }
        Ok(())
    }

    /// Fetch options chain for a symbol with optional expiration filter.
    pub async fn fetch_options_chain(
        &self,
        symbol: &str,
        expiration_timestamp: Option<i64>,
    ) -> Result<ApiResponse> {
        self.ensure_authenticated().await?;

        // Wait for rate limiter permit (from yf-common)
        wait_for_permit(&self.rate_limiter).await;

        let crumb = self
            .auth
            .get_crumb()
            .ok_or_else(|| YfOptionsError::ApiError("No crumb available after auth".to_string()))?;

        let mut url = format!("{}/{}?crumb={}", OPTIONS_URL, symbol, crumb);
        if let Some(timestamp) = expiration_timestamp {
            url.push_str(&format!("&date={}", timestamp));
        }

        debug!("Fetching options from: {}", url);

        let response = self.client.get(&url).send().await?;

        match response.status().as_u16() {
            200 => {
                let api_response: ApiResponse = response.json().await?;
                if api_response.option_chain.result.is_empty() {
                    return Err(YfOptionsError::NoDataError(symbol.to_string()));
                }
                Ok(api_response)
            }
            401 => {
                // Crumb expired — re-authenticate and retry once
                warn!("Received 401 Unauthorized — re-authenticating...");
                self.reauthenticate().await?;

                let new_crumb = self.auth.get_crumb().ok_or_else(|| {
                    YfOptionsError::ApiError("No crumb after re-auth".to_string())
                })?;

                let mut retry_url = format!("{}/{}?crumb={}", OPTIONS_URL, symbol, new_crumb);
                if let Some(timestamp) = expiration_timestamp {
                    retry_url.push_str(&format!("&date={}", timestamp));
                }

                // Wait for rate limiter again
                wait_for_permit(&self.rate_limiter).await;

                let retry_response = self.client.get(&retry_url).send().await?;

                if retry_response.status().is_success() {
                    let api_response: ApiResponse = retry_response.json().await?;
                    if api_response.option_chain.result.is_empty() {
                        return Err(YfOptionsError::NoDataError(symbol.to_string()));
                    }
                    Ok(api_response)
                } else {
                    Err(YfOptionsError::ApiError(format!(
                        "Authentication failed after retry: {}",
                        retry_response.status()
                    )))
                }
            }
            status => {
                let error_text = response.text().await.unwrap_or_default();
                Err(YfOptionsError::ApiError(format!(
                    "API returned status {}: {}",
                    status, error_text
                )))
            }
        }
    }

    /// Fetch all expiration dates for a symbol.
    #[allow(dead_code)] // Public API - may be used by library consumers
    pub async fn fetch_all_expirations(&self, symbol: &str) -> Result<Vec<i64>> {
        let response = self.fetch_options_chain(symbol, None).await?;

        if let Some(result) = response.option_chain.result.first() {
            Ok(result.expiration_dates.clone())
        } else {
            Err(YfOptionsError::NoDataError(symbol.to_string()))
        }
    }

    /// Fetch options for a specific expiration date.
    pub async fn fetch_options_for_expiration(
        &self,
        symbol: &str,
        expiration: i64,
    ) -> Result<ApiResponse> {
        self.fetch_options_chain(symbol, Some(expiration)).await
    }
}

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

    #[tokio::test]
    async fn test_client_creation() {
        let client = OptionsClient::new(5);
        assert!(client.is_ok());
    }

    #[tokio::test]
    async fn test_client_not_authenticated_initially() {
        let client = OptionsClient::new(5).unwrap();
        // CrumbAuth starts unauthenticated
        assert!(!client.auth.is_authenticated());
    }

    #[tokio::test]
    #[ignore] // Ignore by default to avoid hitting API during tests
    async fn test_fetch_options_chain() {
        let client = OptionsClient::new(5).unwrap();
        client.authenticate().await.unwrap();
        let result = client.fetch_options_chain("AAPL", None).await;
        let response = result.expect("fetch_options_chain failed");
        assert!(!response.option_chain.result.is_empty());
    }
}