rhood_core/endpoints/
research.rs1use crate::api::paths;
2use crate::client::RobinhoodClient;
3use crate::models::research::*;
4use crate::pagination::ResultsResponse;
5use crate::{Result, RhoodError};
6
7impl RobinhoodClient {
8 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 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), ¶ms)
37 .await?;
38 Ok(resp.results)
39 }
40
41 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 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), ¶ms)
67 .await
68 }
69
70 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 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}