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";
pub struct OptionsClient {
client: reqwest::Client,
auth: CrumbAuth,
rate_limiter: Arc<YfRateLimiter>,
#[allow(dead_code)] retry_config: RetryConfig,
}
impl OptionsClient {
pub fn new(requests_per_minute: u32) -> Result<Self> {
let auth = CrumbAuth::new();
let cookie_jar = auth.cookie_jar();
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,
})
}
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(())
}
async fn reauthenticate(&self) -> Result<()> {
self.auth.clear_credentials();
self.authenticate().await
}
async fn ensure_authenticated(&self) -> Result<()> {
if !self.auth.is_authenticated() {
self.authenticate().await?;
}
Ok(())
}
pub async fn fetch_options_chain(
&self,
symbol: &str,
expiration_timestamp: Option<i64>,
) -> Result<ApiResponse> {
self.ensure_authenticated().await?;
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 => {
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_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
)))
}
}
}
#[allow(dead_code)] 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()))
}
}
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();
assert!(!client.auth.is_authenticated());
}
#[tokio::test]
#[ignore] 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());
}
}