Skip to main content

market_data/publishers/
finnhub.rs

1//! Fetch time series stock data from [Finnhub](https://finnhub.io/docs/api), implements Publisher trait
2
3use chrono::DateTime;
4use serde::{Deserialize, Serialize};
5use url::Url;
6
7use crate::{
8    client::{Interval, MarketSeries, Series},
9    errors::{MarketError, MarketResult},
10    publishers::Publisher,
11};
12
13const BASE_URL: &str = "https://finnhub.io/api/v1/";
14
15/// Fetch time series stock data from [Finnhub](https://finnhub.io/docs/api), implements Publisher trait
16#[derive(Debug)]
17pub struct Finnhub {
18    token: String,
19}
20
21#[derive(Debug, Clone)]
22pub enum FinnhubRequest {
23    Candle {
24        symbol: String,
25        resolution: String,
26        from: i64,
27        to: i64,
28    },
29    Quote {
30        symbol: String,
31    },
32}
33
34impl Finnhub {
35    pub fn new(token: impl Into<String>) -> Self {
36        Finnhub {
37            token: token.into(),
38        }
39    }
40
41    /// Request for daily series
42    pub fn daily_series(&self, symbol: impl Into<String>, from: i64, to: i64) -> FinnhubRequest {
43        FinnhubRequest::Candle {
44            symbol: symbol.into(),
45            resolution: "D".to_string(),
46            from,
47            to,
48        }
49    }
50
51    /// Request for weekly series
52    pub fn weekly_series(&self, symbol: impl Into<String>, from: i64, to: i64) -> FinnhubRequest {
53        FinnhubRequest::Candle {
54            symbol: symbol.into(),
55            resolution: "W".to_string(),
56            from,
57            to,
58        }
59    }
60
61    /// Request for monthly series
62    pub fn monthly_series(&self, symbol: impl Into<String>, from: i64, to: i64) -> FinnhubRequest {
63        FinnhubRequest::Candle {
64            symbol: symbol.into(),
65            resolution: "M".to_string(),
66            from,
67            to,
68        }
69    }
70
71    /// Request for intraday series
72    pub fn intraday_series(
73        &self,
74        symbol: impl Into<String>,
75        from: i64,
76        to: i64,
77        interval: Interval,
78    ) -> MarketResult<FinnhubRequest> {
79        let resolution = match interval {
80            Interval::Min1 => "1".to_string(),
81            Interval::Min5 => "5".to_string(),
82            Interval::Min15 => "15".to_string(),
83            Interval::Min30 => "30".to_string(),
84            Interval::Hour1 => "60".to_string(),
85            _ => {
86                return Err(MarketError::UnsuportedInterval(format!(
87                    "{} interval is not supported by Finnhub",
88                    interval
89                )))
90            }
91        };
92        Ok(FinnhubRequest::Candle {
93            symbol: symbol.into(),
94            resolution,
95            from,
96            to,
97        })
98    }
99
100    /// Request for real-time quote (returns a single bar)
101    pub fn quote(&self, symbol: impl Into<String>) -> FinnhubRequest {
102        FinnhubRequest::Quote {
103            symbol: symbol.into(),
104        }
105    }
106}
107
108impl Publisher for Finnhub {
109    type Request = FinnhubRequest;
110
111    fn create_endpoint(&self, request: &Self::Request) -> MarketResult<Url> {
112        let base_url = Url::parse(BASE_URL)?;
113        match request {
114            FinnhubRequest::Candle {
115                symbol,
116                resolution,
117                from,
118                to,
119            } => {
120                let mut url = base_url.join("stock/candle")?;
121                url.query_pairs_mut()
122                    .append_pair("symbol", symbol)
123                    .append_pair("resolution", resolution)
124                    .append_pair("from", &from.to_string())
125                    .append_pair("to", &to.to_string())
126                    .append_pair("token", &self.token);
127                Ok(url)
128            }
129            FinnhubRequest::Quote { symbol } => {
130                let mut url = base_url.join("quote")?;
131                url.query_pairs_mut()
132                    .append_pair("symbol", symbol)
133                    .append_pair("token", &self.token);
134                Ok(url)
135            }
136        }
137    }
138
139    fn transform_data(&self, data: String, request: &Self::Request) -> MarketResult<MarketSeries> {
140        match request {
141            FinnhubRequest::Candle {
142                symbol, resolution, ..
143            } => {
144                let candles: FinnhubCandles = serde_json::from_str(&data)?;
145
146                let status = match candles.status {
147                    Some(ref s) => s.as_str(),
148                    None => {
149                        if let Some(ref err) = candles.error {
150                            return Err(MarketError::DownloadedData(format!(
151                                "Finnhub error: {}",
152                                err
153                            )));
154                        }
155                        return Err(MarketError::DownloadedData(format!(
156                            "Finnhub response missing status. Response: {}",
157                            data
158                        )));
159                    }
160                };
161
162                if status != "ok" {
163                    return Err(MarketError::DownloadedData(format!(
164                        "Error returned from Finnhub: {}",
165                        candles.error.unwrap_or_else(|| status.to_string())
166                    )));
167                }
168
169                let t = candles
170                    .t
171                    .ok_or_else(|| MarketError::DownloadedData("Missing timestamps".to_string()))?;
172                let o = candles.o.ok_or_else(|| {
173                    MarketError::DownloadedData("Missing open prices".to_string())
174                })?;
175                let h = candles.h.ok_or_else(|| {
176                    MarketError::DownloadedData("Missing high prices".to_string())
177                })?;
178                let l = candles
179                    .l
180                    .ok_or_else(|| MarketError::DownloadedData("Missing low prices".to_string()))?;
181                let c = candles.c.ok_or_else(|| {
182                    MarketError::DownloadedData("Missing close prices".to_string())
183                })?;
184                let v = candles
185                    .v
186                    .ok_or_else(|| MarketError::DownloadedData("Missing volumes".to_string()))?;
187
188                let mut data_series: Vec<Series> = Vec::with_capacity(t.len());
189                for i in 0..t.len() {
190                    let datetime = DateTime::from_timestamp(t[i], 0).ok_or_else(|| {
191                        MarketError::ParsingError(format!("Unable to parse timestamp: {}", t[i]))
192                    })?;
193
194                    data_series.push(Series {
195                        datetime: datetime.naive_utc(),
196                        open: o[i],
197                        close: c[i],
198                        high: h[i],
199                        low: l[i],
200                        volume: v[i] as f64,
201                    });
202                }
203
204                data_series.sort_by_key(|item| item.datetime);
205
206                Ok(MarketSeries {
207                    symbol: symbol.clone(),
208                    interval: match resolution.as_str() {
209                        "D" => Interval::Daily,
210                        "W" => Interval::Weekly,
211                        "M" => Interval::Monthly,
212                        _ => Interval::Daily,
213                    },
214                    data: data_series,
215                })
216            }
217            FinnhubRequest::Quote { symbol } => {
218                let quote: FinnhubQuote = serde_json::from_str(&data)?;
219
220                // If 't' is 0, it often means the symbol was not found
221                if quote.t == 0 {
222                    return Err(MarketError::DownloadedData(format!(
223                        "Finnhub quote returned no data for symbol: {}",
224                        symbol
225                    )));
226                }
227
228                let datetime = DateTime::from_timestamp(quote.t, 0).ok_or_else(|| {
229                    MarketError::ParsingError(format!("Unable to parse timestamp: {}", quote.t))
230                })?;
231
232                let series = Series {
233                    datetime: datetime.naive_utc(),
234                    open: quote.o,
235                    close: quote.c,
236                    high: quote.h,
237                    low: quote.l,
238                    volume: 0.0, // Quote doesn't return volume
239                };
240
241                Ok(MarketSeries {
242                    symbol: symbol.clone(),
243                    interval: Interval::Daily,
244                    data: vec![series],
245                })
246            }
247        }
248    }
249}
250
251#[derive(Debug, Deserialize, Serialize)]
252struct FinnhubCandles {
253    #[serde(rename = "c")]
254    c: Option<Vec<f32>>,
255    #[serde(rename = "h")]
256    h: Option<Vec<f32>>,
257    #[serde(rename = "l")]
258    l: Option<Vec<f32>>,
259    #[serde(rename = "o")]
260    o: Option<Vec<f32>>,
261    #[serde(rename = "s")]
262    status: Option<String>,
263    #[serde(rename = "t")]
264    t: Option<Vec<i64>>,
265    #[serde(rename = "v")]
266    v: Option<Vec<u64>>,
267    #[serde(rename = "error")]
268    error: Option<String>,
269}
270
271#[derive(Debug, Deserialize, Serialize)]
272struct FinnhubQuote {
273    #[serde(rename = "c")]
274    c: f32,
275    #[serde(rename = "h")]
276    h: f32,
277    #[serde(rename = "l")]
278    l: f32,
279    #[serde(rename = "o")]
280    o: f32,
281    #[serde(rename = "pc")]
282    pc: f32,
283    #[serde(rename = "t")]
284    t: i64,
285}