Skip to main content

rhood_core/endpoints/
research.rs

1use crate::api::paths;
2use crate::client::RobinhoodClient;
3use crate::models::research::*;
4use crate::pagination::ResultsResponse;
5use crate::{Result, RhoodError};
6
7impl RobinhoodClient {
8    /// Resolves a ticker symbol to its Robinhood instrument ID.
9    ///
10    /// Routes through the resolver cache via
11    /// [`cached_instrument`](Self::cached_instrument) so repeated resolutions
12    /// for the same symbol within the TTL are served from memory.
13    ///
14    /// # Errors
15    ///
16    /// Returns [`RhoodError::InvalidSymbol`] if the symbol is not found.
17    pub async fn resolve_instrument_id(&self, symbol: &str) -> Result<String> {
18        self.cached_instrument(symbol)
19            .await?
20            .and_then(|instrument| instrument.id.clone())
21            .ok_or_else(|| RhoodError::InvalidSymbol(symbol.to_string()))
22    }
23
24    /// Fetches earnings data for a ticker symbol.
25    ///
26    /// Returns all available earnings records (historical and upcoming).
27    ///
28    /// # Errors
29    ///
30    /// Returns an error if the HTTP request fails or the response cannot be
31    /// deserialized.
32    pub async fn get_earnings(&self, symbol: &str) -> Result<Vec<Earnings>> {
33        let uppercased = symbol.to_uppercase();
34        let params = [("symbol", uppercased.as_str())];
35        let resp: ResultsResponse<Earnings> = self
36            .get_with_params(&self.api_url(paths::EARNINGS), &params)
37            .await?;
38        Ok(resp.results)
39    }
40
41    /// Fetches analyst ratings for a ticker symbol.
42    ///
43    /// Requires instrument ID resolution (one extra API call).
44    ///
45    /// # Errors
46    ///
47    /// Returns [`RhoodError::InvalidSymbol`] if the symbol is not found.
48    /// Returns an error on HTTP or deserialization failures.
49    pub async fn get_ratings(&self, symbol: &str) -> Result<Rating> {
50        let instrument_id = self.resolve_instrument_id(symbol).await?;
51        let url = format!("{}{instrument_id}/", self.api_url(paths::RATINGS));
52        self.get(&url).await
53    }
54
55    /// Fetches recent news articles for a ticker symbol.
56    ///
57    /// Returns paginated results collected into a single vector.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if the HTTP request fails or the response cannot be
62    /// deserialized.
63    pub async fn get_news(&self, symbol: &str) -> Result<Vec<NewsArticle>> {
64        let uppercased = symbol.to_uppercase();
65        let params = [("symbol", uppercased.as_str())];
66        self.get_paginated(&self.api_url(paths::NEWS), &params)
67            .await
68    }
69
70    /// Fetches stock split history for a ticker symbol.
71    ///
72    /// Requires instrument ID resolution (one extra API call). Returns all
73    /// splits collected from paginated results.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`RhoodError::InvalidSymbol`] if the symbol is not found.
78    /// Returns an error on HTTP or deserialization failures.
79    pub async fn get_splits(&self, symbol: &str) -> Result<Vec<StockSplit>> {
80        let instrument_id = self.resolve_instrument_id(symbol).await?;
81        let url = format!(
82            "{}{instrument_id}/splits/",
83            self.api_url(paths::INSTRUMENTS)
84        );
85        self.get_paginated(&url, &[]).await
86    }
87
88    /// Fetches instruments associated with a tag (e.g., "100-most-popular").
89    ///
90    /// Returns the tag metadata and a list of instrument URLs. Use
91    /// [`get_instrument_by_symbol`](Self::get_instrument_by_symbol) to resolve
92    /// individual URLs to symbols if needed.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the HTTP request fails or the response cannot be
97    /// deserialized.
98    pub async fn get_tags(&self, tag: &str) -> Result<TagResult> {
99        let url = format!("{}{tag}/", self.api_url(paths::TAGS));
100        self.get(&url).await
101    }
102}
103
104#[cfg(test)]
105mod endpoint_tests {
106    use crate::client::RobinhoodClient;
107    use crate::config::RhoodConfig;
108    use secrecy::SecretString;
109    use wiremock::matchers::{method, path, query_param};
110    use wiremock::{Mock, MockServer, ResponseTemplate};
111
112    async fn client_for_server(base_url: &str) -> (tempfile::TempDir, RobinhoodClient) {
113        let dir = tempfile::tempdir().unwrap();
114        let mut config = RhoodConfig::default();
115        config.auth.token_cache_path = dir
116            .path()
117            .join("nonexistent-token.json")
118            .to_str()
119            .unwrap()
120            .to_string();
121        config.api.base_url = base_url.to_string();
122        let client = RobinhoodClient::with_config(config).unwrap();
123        client
124            .inject_test_auth(
125                SecretString::from("access-token"),
126                "Bearer".to_string(),
127                SecretString::from("refresh-token"),
128            )
129            .await;
130        (dir, client)
131    }
132
133    #[tokio::test]
134    async fn get_news_follows_next_page_in_order() {
135        let server = MockServer::start().await;
136        let next_url = format!("{}/midlands/news/?cursor=page-2", server.uri());
137        Mock::given(method("GET"))
138            .and(path("/midlands/news/"))
139            .and(query_param("symbol", "AAPL"))
140            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
141                "results": [{"uuid": "news-1", "title": "First"}],
142                "next": next_url,
143                "previous": null
144            })))
145            .mount(&server)
146            .await;
147        Mock::given(method("GET"))
148            .and(path("/midlands/news/"))
149            .and(query_param("cursor", "page-2"))
150            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
151                "results": [{"uuid": "news-2", "title": "Second"}],
152                "next": null,
153                "previous": null
154            })))
155            .mount(&server)
156            .await;
157        let (_dir, client) = client_for_server(&server.uri()).await;
158
159        let articles = client.get_news("aapl").await.unwrap();
160
161        let ids: Vec<_> = articles
162            .iter()
163            .filter_map(|article| article.uuid.as_deref())
164            .collect();
165        assert_eq!(ids, ["news-1", "news-2"]);
166    }
167}
168
169#[cfg(test)]
170#[expect(
171    clippy::let_underscore_must_use,
172    reason = "compile-only async helpers prove public endpoint signatures without executing requests"
173)]
174mod tests {
175    use crate::models::research::{Earnings, NewsArticle, RatingSummary, StockSplit, TagResult};
176
177    #[test]
178    fn earnings_deserializes_full() {
179        let json = r#"{
180            "symbol": "AAPL",
181            "instrument": "https://api.robinhood.com/instruments/abc/",
182            "year": 2026,
183            "quarter": 1,
184            "eps": {"estimate": "1.50", "actual": "1.65"},
185            "report": {"date": "2026-04-25", "timing": "am", "verified": true}
186        }"#;
187        let e: Earnings = serde_json::from_str(json).unwrap();
188        assert_eq!(e.symbol.as_deref(), Some("AAPL"));
189        assert_eq!(e.year, Some(2026));
190        assert_eq!(e.quarter, Some(1));
191        let eps = e.eps.unwrap();
192        assert_eq!(eps.estimate.as_deref(), Some("1.50"));
193        assert_eq!(eps.actual.as_deref(), Some("1.65"));
194        let report = e.report.unwrap();
195        assert_eq!(report.timing.as_deref(), Some("am"));
196    }
197
198    #[test]
199    fn earnings_handles_missing_fields() {
200        let json = r#"{"symbol": "TSLA"}"#;
201        let e: Earnings = serde_json::from_str(json).unwrap();
202        assert_eq!(e.symbol.as_deref(), Some("TSLA"));
203        assert!(e.eps.is_none());
204        assert!(e.report.is_none());
205    }
206
207    #[test]
208    fn rating_summary_computed_fields() {
209        let summary = RatingSummary {
210            num_buy_ratings: Some(10),
211            num_hold_ratings: Some(5),
212            num_sell_ratings: Some(2),
213        };
214        assert_eq!(summary.total(), 17);
215        let buy_pct = summary.buy_pct();
216        assert!((buy_pct - 58.82).abs() < 0.1);
217    }
218
219    #[test]
220    fn rating_summary_zero_total() {
221        let summary = RatingSummary {
222            num_buy_ratings: None,
223            num_hold_ratings: None,
224            num_sell_ratings: None,
225        };
226        assert_eq!(summary.total(), 0);
227        assert!((summary.buy_pct() - 0.0).abs() < f64::EPSILON);
228    }
229
230    #[test]
231    fn news_article_deserializes() {
232        let json = r#"{
233            "uuid": "news-001",
234            "title": "Apple Reports Record Quarter",
235            "source": "Reuters",
236            "url": "https://example.com/article",
237            "summary": "Apple beat estimates",
238            "published_at": "2026-04-01T10:00:00Z",
239            "related_instruments": ["https://api.robinhood.com/instruments/abc/"],
240            "preview_image_url": "https://example.com/img.jpg"
241        }"#;
242        let article: NewsArticle = serde_json::from_str(json).unwrap();
243        assert_eq!(
244            article.title.as_deref(),
245            Some("Apple Reports Record Quarter")
246        );
247        assert_eq!(article.source.as_deref(), Some("Reuters"));
248        assert!(article.related_instruments.is_some());
249    }
250
251    #[test]
252    fn stock_split_deserializes() {
253        let json = r#"{
254            "url": "https://api.robinhood.com/instruments/abc/splits/s1/",
255            "instrument": "https://api.robinhood.com/instruments/abc/",
256            "execution_date": "2022-06-06",
257            "multiplier": "4.00000000",
258            "divisor": "1.00000000"
259        }"#;
260        let split: StockSplit = serde_json::from_str(json).unwrap();
261        assert_eq!(split.execution_date.as_deref(), Some("2022-06-06"));
262        assert_eq!(split.multiplier.as_deref(), Some("4.00000000"));
263    }
264
265    #[test]
266    fn tag_result_deserializes() {
267        let json = r#"{
268            "name": "Top 100 Most Popular",
269            "slug": "100-most-popular",
270            "instruments": [
271                "https://api.robinhood.com/instruments/aaa/",
272                "https://api.robinhood.com/instruments/bbb/"
273            ]
274        }"#;
275        let tag: TagResult = serde_json::from_str(json).unwrap();
276        assert_eq!(tag.name.as_deref(), Some("Top 100 Most Popular"));
277        assert_eq!(tag.slug.as_deref(), Some("100-most-popular"));
278        let instruments = tag.instruments.unwrap();
279        assert_eq!(instruments.len(), 2);
280    }
281
282    #[test]
283    fn resolve_instrument_id_signature_exists() {
284        async fn _assert(client: &crate::RobinhoodClient) {
285            let _ = client.resolve_instrument_id("AAPL").await;
286        }
287    }
288
289    #[test]
290    fn research_endpoint_signatures_exist() {
291        async fn _assert(client: &crate::RobinhoodClient) {
292            let _ = client.get_earnings("AAPL").await;
293            let _ = client.get_ratings("AAPL").await;
294            let _ = client.get_news("AAPL").await;
295            let _ = client.get_splits("AAPL").await;
296            let _ = client.get_tags("100-most-popular").await;
297        }
298    }
299}