Skip to main content

pine_data/
yahoo.rs

1//! Bars from Yahoo Finance's chart endpoint
2
3use crate::{fetch, DataError};
4use pine_core::{Data, DataProvider, Ohlcv, ProviderError, SymInfo, Timeframe};
5use serde::Deserialize;
6
7/// Yahoo Finance's chart endpoint (the one `yfinance` uses) as a [`DataProvider`]
8/// for equities, ETFs, indices, FX and crypto: it fetches whatever symbol and
9/// timeframe are asked for.
10///
11/// Yahoo limits how far back the finer intervals reach — minute data only goes
12/// back days — so a range it will not serve comes back empty. Widen it with
13/// [`range`](Self::range).
14///
15/// ```no_run
16/// # use pine_data::YahooSource;
17/// # use pine_core::DataProvider;
18/// let data = YahooSource::new().range("6mo").request("AAPL", "1D".parse()?)?;
19/// # Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
20/// ```
21#[derive(Debug, Clone)]
22pub struct YahooSource {
23    range: String,
24}
25
26impl Default for YahooSource {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl YahooSource {
33    pub fn new() -> Self {
34        Self {
35            range: "1mo".to_string(),
36        }
37    }
38
39    /// How far back to fetch: `"1d"`, `"5d"`, `"1mo"`, `"1y"`, `"max"`, …
40    pub fn range(mut self, range: &str) -> Self {
41        self.range = range.to_string();
42        self
43    }
44
45    /// A timeframe as Yahoo spells its intervals: whole hours as `"1h"`, and
46    /// `"1wk"` / `"1mo"` for the longer periods.
47    fn interval(tf: &Timeframe) -> String {
48        match tf.as_minutes() {
49            Some(minutes) if tf.is_minutes() && minutes % 60 == 0 => format!("{}h", minutes / 60),
50            _ if tf.is_minutes() => format!("{}m", tf.multiplier),
51            _ if tf.is_daily() => format!("{}d", tf.multiplier),
52            _ if tf.is_weekly() => format!("{}wk", tf.multiplier),
53            _ if tf.is_monthly() => format!("{}mo", tf.multiplier),
54            _ => format!("{}m", tf.multiplier),
55        }
56    }
57}
58
59#[derive(Debug, Deserialize)]
60struct HttpResult {
61    chart: Chart,
62}
63
64#[derive(Debug, Deserialize)]
65struct Chart {
66    result: Option<Vec<Res>>,
67    error: Option<ChartError>,
68}
69
70#[derive(Debug, Deserialize)]
71struct ChartError {
72    code: String,
73    description: String,
74}
75
76#[derive(Debug, Deserialize)]
77struct Res {
78    meta: Metadata,
79    #[serde(default)]
80    timestamp: Vec<i64>,
81    indicators: Indicators,
82}
83
84#[derive(Debug, Deserialize)]
85#[serde(rename_all = "camelCase")]
86struct Metadata {
87    exchange_name: Option<String>,
88    currency: Option<String>,
89}
90
91#[derive(Debug, Deserialize)]
92struct Indicators {
93    #[serde(default)]
94    quote: Vec<Quote>,
95}
96
97#[derive(Debug, Default, Deserialize)]
98struct Quote {
99    #[serde(default)]
100    open: Vec<Option<f64>>,
101    #[serde(default)]
102    high: Vec<Option<f64>>,
103    #[serde(default)]
104    low: Vec<Option<f64>>,
105    #[serde(default)]
106    close: Vec<Option<f64>>,
107    #[serde(default)]
108    volume: Vec<Option<f64>>,
109}
110
111impl DataProvider for YahooSource {
112    fn request(&self, symbol: &str, timeframe: Timeframe) -> Result<Data, ProviderError> {
113        let url = format!(
114            "https://query1.finance.yahoo.com/v8/finance/chart/{}?interval={}&range={}",
115            symbol,
116            Self::interval(&timeframe),
117            self.range
118        );
119        let body = fetch(&url)?;
120
121        let bad = |message: String| DataError::Provider {
122            provider: "yahoo",
123            message,
124        };
125
126        let response: HttpResult =
127            serde_json::from_str(&body).map_err(|e| bad(format!("{e}: {body:.200}")))?;
128
129        if let Some(error) = response.chart.error {
130            return Err(bad(format!("{}: {}", error.code, error.description)).into());
131        }
132
133        let result = response
134            .chart
135            .result
136            .and_then(|results| results.into_iter().next())
137            .ok_or_else(|| bad(format!("no data for {symbol}")))?;
138        let quote = result
139            .indicators
140            .quote
141            .into_iter()
142            .next()
143            .unwrap_or_default();
144
145        let rows = (0..result.timestamp.len())
146            .filter_map(|i| {
147                let at = |column: &[Option<f64>]| column.get(i).copied().flatten();
148                Some(Ohlcv {
149                    // Yahoo timestamps are seconds; a bar's time is in ms.
150                    time: result.timestamp.get(i)? * 1000,
151                    open: at(&quote.open)?,
152                    high: at(&quote.high)?,
153                    low: at(&quote.low)?,
154                    close: at(&quote.close)?,
155                    volume: at(&quote.volume).unwrap_or(0.0),
156                })
157            })
158            .collect::<Vec<_>>();
159
160        let exchange = result.meta.exchange_name.unwrap_or("YAHOO".to_string());
161        let currency = result.meta.currency.unwrap_or_default();
162
163        let data = Data::from_ohlcv(rows).with_syminfo(SymInfo {
164            ticker: symbol.to_string(),
165            tickerid: format!("{exchange}:{symbol}"),
166            prefix: exchange,
167            currency,
168            ..SymInfo::default()
169        });
170
171        // The requested timeframe is authoritative. Inference would be wrong
172        // here: an equity session leaves a short last bar and uneven gaps.
173        Ok(data)
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_yahoo() {
183        let data = YahooSource::new()
184            .range("6mo")
185            .request("AAPL", "1D".parse().unwrap())
186            .unwrap();
187
188        assert_ne!(data.bars.len(), 0);
189    }
190}