nt_execution/
yahoo_finance.rs

1// Yahoo Finance integration for historical data
2//
3// Free, unlimited API access (unofficial)
4// Features:
5// - Historical price data (OHLCV)
6// - Real-time quotes
7// - Company fundamentals
8// - Options data
9
10use chrono::{DateTime, Utc};
11use reqwest::Client;
12use rust_decimal::Decimal;
13use serde::{Deserialize, Serialize};
14use std::time::Duration;
15use tracing::{debug, error};
16
17/// Yahoo Finance configuration
18#[derive(Debug, Clone)]
19pub struct YahooFinanceConfig {
20    /// Request timeout
21    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
32/// Yahoo Finance client
33pub struct YahooFinanceClient {
34    client: Client,
35    config: YahooFinanceConfig,
36    base_url: String,
37}
38
39impl YahooFinanceClient {
40    /// Create a new Yahoo Finance client
41    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    /// Get historical data for a symbol
56    pub async fn get_historical(
57        &self,
58        symbol: &str,
59        period1: DateTime<Utc>,
60        period2: DateTime<Utc>,
61        interval: &str, // 1d, 1wk, 1mo
62    ) -> 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(&params)
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, &timestamp) 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    /// Get current quote for a symbol
129    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(&params)
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}