1use std::collections::HashMap;
2
3use anyhow::{bail, Context, Result};
4use futures::AsyncReadExt;
5use http::{header, Request, Uri};
6use isahc::{AsyncReadResponseExt, HttpClient};
7use serde::de::DeserializeOwned;
8
9use crate::model::{Chart, ChartData, Company, CompanyData, CrumbData, Options, OptionsHeader};
10use crate::{Interval, Range};
11
12#[derive(Debug)]
13pub struct Client {
14 client: HttpClient,
15 base: String,
16}
17
18impl Client {
19 pub fn new() -> Self {
20 Client::default()
21 }
22
23 fn get_url(
24 &self,
25 version: Version,
26 path: &str,
27 params: Option<HashMap<&str, String>>,
28 ) -> Result<http::Uri> {
29 if let Some(params) = params {
30 let params = serde_urlencoded::to_string(params).unwrap_or_else(|_| String::from(""));
31 let uri = format!("{}/{}/{}?{}", self.base, version.as_str(), path, params);
32 Ok(uri.parse::<Uri>()?)
33 } else {
34 let uri = format!("{}/{}/{}", self.base, version.as_str(), path);
35 Ok(uri.parse::<Uri>()?)
36 }
37 }
38
39 async fn get<T: DeserializeOwned>(&self, url: Uri, cookie: Option<String>) -> Result<T> {
40 let mut req = Request::builder()
41 .method(http::Method::GET)
42 .uri(url)
43 .header(header::USER_AGENT, "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36");
44
45 if let Some(cookie) = cookie {
46 req = req.header(header::COOKIE, cookie);
47 }
48
49 let res = self
50 .client
51 .send_async(req.body(())?)
52 .await
53 .context("Failed to get request")?;
54
55 let mut body = res.into_body();
56 let mut bytes = Vec::new();
57 body.read_to_end(&mut bytes).await?;
58
59 let response = serde_json::from_slice(&bytes)?;
60
61 Ok(response)
62 }
63
64 pub async fn get_chart_data(
65 &self,
66 symbol: &str,
67 interval: Interval,
68 range: Range,
69 include_pre_post: bool,
70 ) -> Result<ChartData> {
71 let mut params = HashMap::new();
72 params.insert("interval", format!("{}", interval));
73 params.insert("range", format!("{}", range));
74
75 if include_pre_post {
76 params.insert("includePrePost", format!("{}", true));
77 }
78
79 let url = self.get_url(
80 Version::V8,
81 &format!("finance/chart/{}", symbol),
82 Some(params),
83 )?;
84
85 let response: Chart = self.get(url, None).await?;
86
87 if let Some(err) = response.chart.error {
88 bail!(
89 "Error getting chart data for {}: {}",
90 symbol,
91 err.description
92 );
93 }
94
95 if let Some(mut result) = response.chart.result {
96 if result.len() == 1 {
97 return Ok(result.remove(0));
98 }
99 }
100
101 bail!("Failed to get chart data for {}", symbol);
102 }
103
104 pub async fn get_company_data(
105 &self,
106 symbol: &str,
107 crumb_data: CrumbData,
108 ) -> Result<CompanyData> {
109 let mut params = HashMap::new();
110 params.insert("modules", "price,assetProfile".to_string());
111 params.insert("crumb", crumb_data.crumb);
112
113 let url = self.get_url(
114 Version::V10,
115 &format!("finance/quoteSummary/{}", symbol),
116 Some(params),
117 )?;
118
119 let response: Company = self.get(url, Some(crumb_data.cookie)).await?;
120
121 if let Some(err) = response.company.error {
122 bail!(
123 "Error getting company data for {}: {}",
124 symbol,
125 err.description
126 );
127 }
128
129 if let Some(mut result) = response.company.result {
130 if result.len() == 1 {
131 return Ok(result.remove(0));
132 }
133 }
134
135 bail!("Failed to get company data for {}", symbol);
136 }
137
138 pub async fn get_options_expiration_dates(&self, symbol: &str) -> Result<Vec<i64>> {
139 let url = self.get_url(Version::V7, &format!("finance/options/{}", symbol), None)?;
140
141 let response: Options = self.get(url, None).await?;
142
143 if let Some(err) = response.option_chain.error {
144 bail!(
145 "Error getting options data for {}: {}",
146 symbol,
147 err.description
148 );
149 }
150
151 if let Some(mut result) = response.option_chain.result {
152 if result.len() == 1 {
153 let options_header = result.remove(0);
154 return Ok(options_header.expiration_dates);
155 }
156 }
157
158 bail!("Failed to get options data for {}", symbol);
159 }
160
161 pub async fn get_options_for_expiration_date(
162 &self,
163 symbol: &str,
164 expiration_date: i64,
165 ) -> Result<OptionsHeader> {
166 let mut params = HashMap::new();
167 params.insert("date", format!("{}", expiration_date));
168
169 let url = self.get_url(
170 Version::V7,
171 &format!("finance/options/{}", symbol),
172 Some(params),
173 )?;
174
175 let response: Options = self.get(url, None).await?;
176
177 if let Some(err) = response.option_chain.error {
178 bail!(
179 "Error getting options data for {}: {}",
180 symbol,
181 err.description
182 );
183 }
184
185 if let Some(mut result) = response.option_chain.result {
186 if result.len() == 1 {
187 let options_header = result.remove(0);
188
189 return Ok(options_header);
190 }
191 }
192
193 bail!("Failed to get options data for {}", symbol);
194 }
195
196 pub async fn get_crumb(&self) -> Result<CrumbData> {
197 let res = self
198 .client
199 .get_async("https://fc.yahoo.com")
200 .await
201 .context("Failed to get request")?;
202
203 let Some(cookie) = res
204 .headers()
205 .get(header::SET_COOKIE)
206 .and_then(|header| header.to_str().ok())
207 .and_then(|s| s.split_once(';').map(|(value, _)| value))
208 else {
209 bail!("Couldn't fetch cookie");
210 };
211
212 let request = Request::builder()
213 .uri(self.get_url(Version::V1, "test/getcrumb", None)?)
214 .header(header::USER_AGENT, "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36")
215 .header(header::COOKIE, cookie)
216 .method(http::Method::GET)
217 .body(())?;
218 let mut res = self.client.send_async(request).await?;
219
220 let crumb = res.text().await?;
221
222 Ok(CrumbData {
223 cookie: cookie.to_string(),
224 crumb,
225 })
226 }
227}
228
229impl Default for Client {
230 fn default() -> Client {
231 #[allow(unused_mut)]
232 let mut builder = HttpClient::builder();
233
234 #[cfg(target_os = "android")]
235 {
236 use isahc::config::{Configurable, SslOption};
237
238 builder = builder.ssl_options(SslOption::DANGER_ACCEPT_INVALID_CERTS);
239 }
240
241 let client = builder.build().unwrap();
242
243 let base = String::from("https://query1.finance.yahoo.com");
244
245 Client { client, base }
246 }
247}
248
249#[derive(Debug, Clone)]
250pub enum Version {
251 V1,
252 V7,
253 V8,
254 V10,
255}
256
257impl Version {
258 fn as_str(&self) -> &'static str {
259 match self {
260 Version::V1 => "v1",
261 Version::V7 => "v7",
262 Version::V8 => "v8",
263 Version::V10 => "v10",
264 }
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[async_std::test]
273 async fn test_company_data() {
274 let client = Client::new();
275
276 let symbols = vec!["SPY", "AAPL", "AMD", "TSLA", "ES=F", "BTC-USD", "DX-Y.NYB"];
277
278 let crumb = client.get_crumb().await.unwrap();
279
280 for symbol in symbols {
281 let data = client.get_company_data(symbol, crumb.clone()).await;
282
283 if let Err(e) = data {
284 println!("{}", e);
285
286 panic!();
287 }
288 }
289 }
290
291 #[async_std::test]
292 async fn test_options_data() {
293 let client = Client::new();
294
295 let symbol = "SPY";
296
297 let exp_dates = client.get_options_expiration_dates(symbol).await;
298
299 match exp_dates {
300 Err(e) => {
301 println!("{}", e);
302
303 panic!();
304 }
305 Ok(dates) => {
306 for date in dates {
307 let options = client.get_options_for_expiration_date(symbol, date).await;
308
309 if let Err(e) = options {
310 println!("{}", e);
311
312 panic!();
313 }
314 }
315 }
316 }
317 }
318
319 #[async_std::test]
320 async fn test_chart_data() {
321 let client = Client::new();
322
323 let combinations = [
324 (Range::Year5, Interval::Minute1),
325 (Range::Day1, Interval::Minute1),
326 (Range::Day5, Interval::Minute5),
327 (Range::Month1, Interval::Minute30),
328 (Range::Month3, Interval::Minute60),
329 (Range::Month6, Interval::Minute60),
330 (Range::Year1, Interval::Day1),
331 (Range::Year5, Interval::Day1),
332 ];
333
334 let ticker = "SPY";
335
336 for (idx, (range, interval)) in combinations.iter().enumerate() {
337 let data = client.get_chart_data(ticker, *interval, *range, true).await;
338
339 if let Err(e) = data {
340 println!("{}", e);
341
342 if idx > 0 {
343 panic!();
344 }
345 } else if idx == 0 {
346 panic!();
347 }
348 }
349 }
350}