1use 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
18pub struct OptionsClient {
20 client: reqwest::Client,
22 auth: CrumbAuth,
24 rate_limiter: Arc<YfRateLimiter>,
26 #[allow(dead_code)] retry_config: RetryConfig,
29}
30
31impl OptionsClient {
32 pub fn new(requests_per_minute: u32) -> Result<Self> {
36 let auth = CrumbAuth::new();
37 let cookie_jar = auth.cookie_jar();
38
39 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 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 async fn reauthenticate(&self) -> Result<()> {
71 self.auth.clear_credentials();
72 self.authenticate().await
73 }
74
75 async fn ensure_authenticated(&self) -> Result<()> {
77 if !self.auth.is_authenticated() {
78 self.authenticate().await?;
79 }
80 Ok(())
81 }
82
83 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_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 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_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 #[allow(dead_code)] 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 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 assert!(!client.auth.is_authenticated());
195 }
196
197 #[tokio::test]
198 #[ignore] 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}