Skip to main content

yf_options/
client.rs

1//! Yahoo Finance options client using yf-common shared infrastructure.
2//!
3//! Uses CrumbAuth from yf-common for cookie/crumb authentication,
4//! and YfRateLimiter for rate limiting. The reqwest Client is built
5//! locally with cookie jar support (required for auth flow).
6
7use crate::error::{Result, YfOptionsError};
8use crate::models::ApiResponse;
9use std::sync::Arc;
10use tracing::{debug, warn};
11use yf_common::auth::{AuthProvider, CrumbAuth};
12use yf_common::rate_limit::{wait_for_permit, RateLimitConfig, YfRateLimiter};
13use yf_common::retry::RetryConfig;
14
15const 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";
16const OPTIONS_URL: &str = "https://query1.finance.yahoo.com/v7/finance/options";
17
18/// Yahoo Finance options client backed by yf-common auth and rate limiting.
19pub struct OptionsClient {
20    /// Raw reqwest client with cookie jar for auth support
21    client: reqwest::Client,
22    /// Shared authentication provider (cookie/crumb flow)
23    auth: CrumbAuth,
24    /// Rate limiter from yf-common
25    rate_limiter: Arc<YfRateLimiter>,
26    /// Retry configuration from yf-common
27    #[allow(dead_code)] // Available for future retry integration
28    retry_config: RetryConfig,
29}
30
31impl OptionsClient {
32    /// Create a new options client with rate limiting.
33    ///
34    /// The client is NOT authenticated yet — call `authenticate()` before fetching data.
35    pub fn new(requests_per_minute: u32) -> Result<Self> {
36        let auth = CrumbAuth::new();
37        let cookie_jar = auth.cookie_jar();
38
39        // Build reqwest client with cookie jar (required for crumb auth flow)
40        let client = reqwest::Client::builder()
41            .user_agent(USER_AGENT)
42            .timeout(std::time::Duration::from_secs(30))
43            .cookie_provider(cookie_jar)
44            .build()?;
45
46        let rate_limit_config = RateLimitConfig::new(requests_per_minute);
47        let rate_limiter = rate_limit_config.build_limiter();
48        let retry_config = RetryConfig::default();
49
50        Ok(Self {
51            client,
52            auth,
53            rate_limiter,
54            retry_config,
55        })
56    }
57
58    /// Initialize session by fetching cookies and crumb via yf-common CrumbAuth.
59    pub async fn authenticate(&self) -> Result<()> {
60        debug!("Authenticating with Yahoo Finance via yf-common CrumbAuth...");
61        self.auth
62            .authenticate(&self.client)
63            .await
64            .map_err(YfOptionsError::Common)?;
65        debug!("Authentication successful");
66        Ok(())
67    }
68
69    /// Re-authenticate (clear credentials and authenticate again).
70    async fn reauthenticate(&self) -> Result<()> {
71        self.auth.clear_credentials();
72        self.authenticate().await
73    }
74
75    /// Check if we have valid credentials, authenticate if not.
76    async fn ensure_authenticated(&self) -> Result<()> {
77        if !self.auth.is_authenticated() {
78            self.authenticate().await?;
79        }
80        Ok(())
81    }
82
83    /// Fetch options chain for a symbol with optional expiration filter.
84    pub async fn fetch_options_chain(
85        &self,
86        symbol: &str,
87        expiration_timestamp: Option<i64>,
88    ) -> Result<ApiResponse> {
89        self.ensure_authenticated().await?;
90
91        // Wait for rate limiter permit (from yf-common)
92        wait_for_permit(&self.rate_limiter).await;
93
94        let crumb = self
95            .auth
96            .get_crumb()
97            .ok_or_else(|| YfOptionsError::ApiError("No crumb available after auth".to_string()))?;
98
99        let mut url = format!("{}/{}?crumb={}", OPTIONS_URL, symbol, crumb);
100        if let Some(timestamp) = expiration_timestamp {
101            url.push_str(&format!("&date={}", timestamp));
102        }
103
104        debug!("Fetching options from: {}", url);
105
106        let response = self.client.get(&url).send().await?;
107
108        match response.status().as_u16() {
109            200 => {
110                let api_response: ApiResponse = response.json().await?;
111                if api_response.option_chain.result.is_empty() {
112                    return Err(YfOptionsError::NoDataError(symbol.to_string()));
113                }
114                Ok(api_response)
115            }
116            401 => {
117                // Crumb expired — re-authenticate and retry once
118                warn!("Received 401 Unauthorized — re-authenticating...");
119                self.reauthenticate().await?;
120
121                let new_crumb = self.auth.get_crumb().ok_or_else(|| {
122                    YfOptionsError::ApiError("No crumb after re-auth".to_string())
123                })?;
124
125                let mut retry_url = format!("{}/{}?crumb={}", OPTIONS_URL, symbol, new_crumb);
126                if let Some(timestamp) = expiration_timestamp {
127                    retry_url.push_str(&format!("&date={}", timestamp));
128                }
129
130                // Wait for rate limiter again
131                wait_for_permit(&self.rate_limiter).await;
132
133                let retry_response = self.client.get(&retry_url).send().await?;
134
135                if retry_response.status().is_success() {
136                    let api_response: ApiResponse = retry_response.json().await?;
137                    if api_response.option_chain.result.is_empty() {
138                        return Err(YfOptionsError::NoDataError(symbol.to_string()));
139                    }
140                    Ok(api_response)
141                } else {
142                    Err(YfOptionsError::ApiError(format!(
143                        "Authentication failed after retry: {}",
144                        retry_response.status()
145                    )))
146                }
147            }
148            status => {
149                let error_text = response.text().await.unwrap_or_default();
150                Err(YfOptionsError::ApiError(format!(
151                    "API returned status {}: {}",
152                    status, error_text
153                )))
154            }
155        }
156    }
157
158    /// Fetch all expiration dates for a symbol.
159    #[allow(dead_code)] // Public API - may be used by library consumers
160    pub async fn fetch_all_expirations(&self, symbol: &str) -> Result<Vec<i64>> {
161        let response = self.fetch_options_chain(symbol, None).await?;
162
163        if let Some(result) = response.option_chain.result.first() {
164            Ok(result.expiration_dates.clone())
165        } else {
166            Err(YfOptionsError::NoDataError(symbol.to_string()))
167        }
168    }
169
170    /// Fetch options for a specific expiration date.
171    pub async fn fetch_options_for_expiration(
172        &self,
173        symbol: &str,
174        expiration: i64,
175    ) -> Result<ApiResponse> {
176        self.fetch_options_chain(symbol, Some(expiration)).await
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[tokio::test]
185    async fn test_client_creation() {
186        let client = OptionsClient::new(5);
187        assert!(client.is_ok());
188    }
189
190    #[tokio::test]
191    async fn test_client_not_authenticated_initially() {
192        let client = OptionsClient::new(5).unwrap();
193        // CrumbAuth starts unauthenticated
194        assert!(!client.auth.is_authenticated());
195    }
196
197    #[tokio::test]
198    #[ignore] // Ignore by default to avoid hitting API during tests
199    async fn test_fetch_options_chain() {
200        let client = OptionsClient::new(5).unwrap();
201        client.authenticate().await.unwrap();
202        let result = client.fetch_options_chain("AAPL", None).await;
203        let response = result.expect("fetch_options_chain failed");
204        assert!(!response.option_chain.result.is_empty());
205    }
206}