1use chrono::{DateTime, Utc};
11use reqwest::Client;
12use rust_decimal::Decimal;
13use serde::{Deserialize, Serialize};
14use std::time::Duration;
15use tracing::{debug, error};
16
17#[derive(Debug, Clone)]
19pub struct YahooFinanceConfig {
20 pub timeout: Duration,
22}
23
24impl Default for YahooFinanceConfig {
25 fn default() -> Self {
26 Self {
27 timeout: Duration::from_secs(30),
28 }
29 }
30}
31
32pub struct YahooFinanceClient {
34 client: Client,
35 config: YahooFinanceConfig,
36 base_url: String,
37}
38
39impl YahooFinanceClient {
40 pub fn new(config: YahooFinanceConfig) -> Self {
42 let client = Client::builder()
43 .timeout(config.timeout)
44 .user_agent("Mozilla/5.0")
45 .build()
46 .expect("Failed to create HTTP client");
47
48 Self {
49 client,
50 config,
51 base_url: "https://query2.finance.yahoo.com".to_string(),
52 }
53 }
54
55 pub async fn get_historical(
57 &self,
58 symbol: &str,
59 period1: DateTime<Utc>,
60 period2: DateTime<Utc>,
61 interval: &str, ) -> Result<Vec<YahooBar>, YahooFinanceError> {
63 let url = format!(
64 "{}/v8/finance/chart/{}",
65 self.base_url, symbol
66 );
67
68 let params = [
69 ("period1", period1.timestamp().to_string()),
70 ("period2", period2.timestamp().to_string()),
71 ("interval", interval.to_string()),
72 ("includePrePost", "false".to_string()),
73 ];
74
75 debug!("Yahoo Finance request: historical data for {}", symbol);
76
77 let response = self
78 .client
79 .get(&url)
80 .query(¶ms)
81 .send()
82 .await?;
83
84 if response.status().is_success() {
85 let data: YahooChartResponse = response.json().await?;
86
87 if let Some(error) = data.chart.error {
88 return Err(YahooFinanceError::ApiError(error.description));
89 }
90
91 if let Some(result) = data.chart.result.first() {
92 let timestamps = &result.timestamp;
93 let quote = &result.indicators.quote.first()
94 .ok_or_else(|| YahooFinanceError::ApiError("No quote data".to_string()))?;
95
96 let mut bars = Vec::new();
97
98 for (i, ×tamp) in timestamps.iter().enumerate() {
99 if let (Some(open), Some(high), Some(low), Some(close), Some(volume)) = (
100 quote.open.get(i).and_then(|v| *v),
101 quote.high.get(i).and_then(|v| *v),
102 quote.low.get(i).and_then(|v| *v),
103 quote.close.get(i).and_then(|v| *v),
104 quote.volume.get(i).and_then(|v| *v),
105 ) {
106 bars.push(YahooBar {
107 timestamp,
108 open: Decimal::try_from(open).unwrap_or_default(),
109 high: Decimal::try_from(high).unwrap_or_default(),
110 low: Decimal::try_from(low).unwrap_or_default(),
111 close: Decimal::try_from(close).unwrap_or_default(),
112 volume: volume as i64,
113 });
114 }
115 }
116
117 Ok(bars)
118 } else {
119 Err(YahooFinanceError::ApiError("No data found".to_string()))
120 }
121 } else {
122 let error_text = response.text().await.unwrap_or_default();
123 error!("Yahoo Finance error: {}", error_text);
124 Err(YahooFinanceError::ApiError(error_text))
125 }
126 }
127
128 pub async fn get_quote(&self, symbol: &str) -> Result<YahooQuote, YahooFinanceError> {
130 let url = format!("{}/v7/finance/quote", self.base_url);
131
132 let params = [("symbols", symbol)];
133
134 let response = self
135 .client
136 .get(&url)
137 .query(¶ms)
138 .send()
139 .await?;
140
141 if response.status().is_success() {
142 let data: YahooQuoteResponse = response.json().await?;
143
144 if let Some(result) = data.quote_response.result.first() {
145 Ok(YahooQuote {
146 symbol: result.symbol.clone(),
147 price: result.regular_market_price.unwrap_or_default(),
148 change: result.regular_market_change.unwrap_or_default(),
149 change_percent: result.regular_market_change_percent.unwrap_or_default(),
150 volume: result.regular_market_volume.unwrap_or(0),
151 market_cap: result.market_cap,
152 })
153 } else {
154 Err(YahooFinanceError::ApiError("No quote found".to_string()))
155 }
156 } else {
157 let error_text = response.text().await.unwrap_or_default();
158 Err(YahooFinanceError::ApiError(error_text))
159 }
160 }
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct YahooBar {
165 pub timestamp: i64,
166 pub open: Decimal,
167 pub high: Decimal,
168 pub low: Decimal,
169 pub close: Decimal,
170 pub volume: i64,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct YahooQuote {
175 pub symbol: String,
176 pub price: Decimal,
177 pub change: Decimal,
178 pub change_percent: Decimal,
179 pub volume: i64,
180 pub market_cap: Option<i64>,
181}
182
183#[derive(Debug, Deserialize)]
184struct YahooChartResponse {
185 chart: YahooChart,
186}
187
188#[derive(Debug, Deserialize)]
189struct YahooChart {
190 result: Vec<YahooChartResult>,
191 error: Option<YahooError>,
192}
193
194#[derive(Debug, Deserialize)]
195struct YahooChartResult {
196 timestamp: Vec<i64>,
197 indicators: YahooIndicators,
198}
199
200#[derive(Debug, Deserialize)]
201struct YahooIndicators {
202 quote: Vec<YahooQuoteData>,
203}
204
205#[derive(Debug, Deserialize)]
206struct YahooQuoteData {
207 open: Vec<Option<f64>>,
208 high: Vec<Option<f64>>,
209 low: Vec<Option<f64>>,
210 close: Vec<Option<f64>>,
211 volume: Vec<Option<i64>>,
212}
213
214#[derive(Debug, Deserialize)]
215struct YahooError {
216 code: String,
217 description: String,
218}
219
220#[derive(Debug, Deserialize)]
221struct YahooQuoteResponse {
222 #[serde(rename = "quoteResponse")]
223 quote_response: YahooQuoteResponseData,
224}
225
226#[derive(Debug, Deserialize)]
227struct YahooQuoteResponseData {
228 result: Vec<YahooQuoteResult>,
229}
230
231#[derive(Debug, Deserialize)]
232struct YahooQuoteResult {
233 symbol: String,
234 #[serde(rename = "regularMarketPrice")]
235 regular_market_price: Option<Decimal>,
236 #[serde(rename = "regularMarketChange")]
237 regular_market_change: Option<Decimal>,
238 #[serde(rename = "regularMarketChangePercent")]
239 regular_market_change_percent: Option<Decimal>,
240 #[serde(rename = "regularMarketVolume")]
241 regular_market_volume: Option<i64>,
242 #[serde(rename = "marketCap")]
243 market_cap: Option<i64>,
244}
245
246#[derive(Debug, thiserror::Error)]
247pub enum YahooFinanceError {
248 #[error("API error: {0}")]
249 ApiError(String),
250
251 #[error("Network error: {0}")]
252 Network(#[from] reqwest::Error),
253
254 #[error("Parse error: {0}")]
255 Parse(#[from] serde_json::Error),
256
257 #[error(transparent)]
258 Other(#[from] anyhow::Error),
259}