tickrs_api/
client.rs

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}