1use std::collections::HashMap;
2use std::sync::Arc;
3
4use crate::api::paths;
5use crate::client::RobinhoodClient;
6use crate::models::stock::*;
7use crate::pagination::ResultsResponse;
8use crate::{Result, RhoodError};
9
10#[derive(serde::Deserialize)]
19struct InstrumentBatchResponse {
20 results: Vec<Option<Instrument>>,
21}
22
23impl RobinhoodClient {
24 pub async fn get_quotes(&self, symbols: &[&str]) -> Result<Vec<StockQuote>> {
34 let joined_symbols = symbols
35 .iter()
36 .map(|symbol| symbol.to_uppercase())
37 .collect::<Vec<_>>()
38 .join(",");
39 let params = [("symbols", joined_symbols.as_str())];
40 let resp: ResultsResponse<StockQuote> = self
41 .get_with_params(&self.api_url(paths::QUOTES), ¶ms)
42 .await?;
43 Ok(resp
44 .results
45 .into_iter()
46 .filter(|quote| quote.symbol.is_some())
47 .collect())
48 }
49
50 pub async fn get_latest_prices(&self, symbols: &[&str]) -> Result<Vec<(String, String)>> {
60 let quotes = self.get_quotes(symbols).await?;
61 Ok(quotes
62 .into_iter()
63 .filter_map(|quote| {
64 let symbol = quote.symbol?;
65 let price = quote
66 .last_extended_hours_trade_price
67 .or(quote.last_trade_price)?;
68 Some((symbol, price))
69 })
70 .collect())
71 }
72
73 pub async fn get_fundamentals(&self, symbols: &[&str]) -> Result<Vec<Fundamentals>> {
81 let joined_symbols = symbols
82 .iter()
83 .map(|symbol| symbol.to_uppercase())
84 .collect::<Vec<_>>()
85 .join(",");
86 let params = [("symbols", joined_symbols.as_str())];
87 let resp: ResultsResponse<Fundamentals> = self
88 .get_with_params(&self.api_url(paths::FUNDAMENTALS), ¶ms)
89 .await?;
90 Ok(resp.results)
91 }
92
93 pub async fn get_stock_historicals(
105 &self,
106 symbols: &[&str],
107 opts: &HistoricalOpts,
108 ) -> Result<Vec<Candle>> {
109 if matches!(
110 opts.bounds,
111 HistoricalBounds::Extended | HistoricalBounds::Trading
112 ) && !matches!(opts.span, HistoricalSpan::Day)
113 {
114 return Err(RhoodError::InvalidParameter(
115 "Extended/trading bounds can only be used with day span".into(),
116 ));
117 }
118
119 let joined_symbols = symbols
120 .iter()
121 .map(|symbol| symbol.to_uppercase())
122 .collect::<Vec<_>>()
123 .join(",");
124 let params = [
125 ("symbols", joined_symbols.as_str()),
126 ("interval", opts.interval.as_str()),
127 ("span", opts.span.as_str()),
128 ("bounds", opts.bounds.as_str()),
129 ];
130
131 let resp: ResultsResponse<HistoricalsResult> = self
132 .get_with_params(&self.api_url(paths::HISTORICALS), ¶ms)
133 .await?;
134
135 let mut candles = Vec::new();
136 for result in resp.results {
137 let symbol = result.symbol.unwrap_or_default();
138 for mut candle in result.historicals {
139 candle.symbol = Some(symbol.clone());
140 candles.push(candle);
141 }
142 }
143 Ok(candles)
144 }
145
146 pub async fn get_instrument_by_symbol(&self, symbol: &str) -> Result<Option<Instrument>> {
155 let uppercased_symbol = symbol.to_uppercase();
156 let params = [("symbol", uppercased_symbol.as_str())];
157 let resp: ResultsResponse<Instrument> = self
158 .get_with_params(&self.api_url(paths::INSTRUMENTS), ¶ms)
159 .await?;
160 Ok(resp.results.into_iter().next())
161 }
162
163 pub async fn cached_instrument(&self, symbol: &str) -> Result<Option<Arc<Instrument>>> {
175 let key = symbol.to_uppercase();
176 if !self.resolvers.enabled {
177 return Ok(self.get_instrument_by_symbol(&key).await?.map(Arc::new));
178 }
179 if let Some(hit) = self.resolvers.instruments_by_symbol.get(&key).await {
180 return Ok(Some(hit));
181 }
182 let Some(instrument) = self.get_instrument_by_symbol(&key).await? else {
183 return Ok(None);
184 };
185 let wrapped = Arc::new(instrument);
186 self.resolvers
187 .instruments_by_symbol
188 .insert(key.clone(), wrapped.clone())
189 .await;
190 if let Some(id) = wrapped.id.as_ref() {
191 self.resolvers
192 .instruments_by_id
193 .insert(id.clone(), key)
194 .await;
195 }
196 Ok(Some(wrapped))
197 }
198
199 pub async fn get_index_instrument(&self, symbol: &str) -> Result<Option<IndexInstrument>> {
208 let uppercased = symbol.to_uppercase();
209 let params = [("symbol", uppercased.as_str())];
210 let resp: ResultsResponse<IndexInstrument> = self
211 .get_with_params(&self.api_url(paths::INDEXES), ¶ms)
212 .await?;
213 Ok(resp.results.into_iter().next())
214 }
215
216 pub async fn cached_index_instrument(
222 &self,
223 symbol: &str,
224 ) -> Result<Option<Arc<IndexInstrument>>> {
225 let key = symbol.to_uppercase();
226 if !self.resolvers.enabled {
227 return Ok(self.get_index_instrument(&key).await?.map(Arc::new));
228 }
229 if let Some(hit) = self.resolvers.index_instruments.get(&key).await {
230 return Ok(Some(hit));
231 }
232 let Some(index) = self.get_index_instrument(&key).await? else {
233 return Ok(None);
234 };
235 let wrapped = Arc::new(index);
236 self.resolvers
237 .index_instruments
238 .insert(key, wrapped.clone())
239 .await;
240 Ok(Some(wrapped))
241 }
242
243 pub async fn resolve_symbols(&self, ids: &[String]) -> Result<HashMap<String, String>> {
260 let mut result: HashMap<String, String> = HashMap::with_capacity(ids.len());
261 let mut misses: Vec<&str> = Vec::new();
262 if self.resolvers.enabled {
263 for id in ids {
264 if let Some(symbol) = self.resolvers.instruments_by_id.get(id).await {
265 result.insert(id.clone(), symbol);
266 } else {
267 misses.push(id.as_str());
268 }
269 }
270 } else {
271 misses = ids.iter().map(String::as_str).collect();
272 }
273 if misses.is_empty() {
274 return Ok(result);
275 }
276 let batch_size = if self.resolvers.enabled {
277 self.resolvers.enrichment_batch_size.max(1)
278 } else {
279 50
280 };
281 for chunk in misses.chunks(batch_size) {
282 let joined = chunk.join(",");
283 let params = [("ids", joined.as_str())];
284 let resp: InstrumentBatchResponse = self
285 .get_with_params(&self.api_url(paths::INSTRUMENTS), ¶ms)
286 .await?;
287 for instrument in resp.results.into_iter().flatten() {
288 if let (Some(id), Some(symbol)) = (instrument.id, instrument.symbol) {
289 if self.resolvers.enabled {
290 self.resolvers
291 .instruments_by_id
292 .insert(id.clone(), symbol.clone())
293 .await;
294 }
295 result.insert(id, symbol);
296 }
297 }
298 }
299 Ok(result)
300 }
301
302 pub async fn get_index_quote(&self, symbol: &str) -> Result<IndexQuote> {
312 let index = self
313 .cached_index_instrument(symbol)
314 .await?
315 .ok_or_else(|| RhoodError::InvalidSymbol(symbol.to_string()))?;
316 let id = index
317 .id
318 .clone()
319 .ok_or_else(|| RhoodError::InvalidSymbol(symbol.to_string()))?;
320 let url = format!("{}{id}/", self.api_url(paths::INDEX_MARKET_DATA));
321 let wrapper: IndexQuoteWrapper = self.get(&url).await?;
322 let mut quote = wrapper.data.data;
323 if quote.symbol.is_none() {
325 quote.symbol = index.symbol.clone();
326 }
327 Ok(quote)
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::models::stock::{Fundamentals, Instrument, StockQuote};
335
336 #[test]
337 fn historical_bounds_validation_extended_with_week_span() {
338 let opts = HistoricalOpts {
339 interval: HistoricalInterval::FiveMinute,
340 span: HistoricalSpan::Week,
341 bounds: HistoricalBounds::Extended,
342 };
343 assert!(matches!(
344 opts.bounds,
345 HistoricalBounds::Extended | HistoricalBounds::Trading
346 ));
347 assert!(!matches!(opts.span, HistoricalSpan::Day));
348 }
349
350 #[test]
351 fn historical_bounds_validation_extended_with_day_span() {
352 let opts = HistoricalOpts {
353 interval: HistoricalInterval::FiveMinute,
354 span: HistoricalSpan::Day,
355 bounds: HistoricalBounds::Extended,
356 };
357 assert!(matches!(opts.span, HistoricalSpan::Day));
358 }
359
360 #[test]
361 fn historical_bounds_validation_regular_with_any_span() {
362 let opts = HistoricalOpts {
363 interval: HistoricalInterval::Day,
364 span: HistoricalSpan::Year,
365 bounds: HistoricalBounds::Regular,
366 };
367 assert!(!matches!(
368 opts.bounds,
369 HistoricalBounds::Extended | HistoricalBounds::Trading
370 ));
371 }
372
373 #[tokio::test]
374 async fn resolve_symbols_cache_only_path_skips_http() {
375 use crate::RhoodConfig;
376 let client = RobinhoodClient::with_config(RhoodConfig::default()).unwrap();
377 client
378 .resolvers
379 .instruments_by_id
380 .insert("u1".to_string(), "AAPL".to_string())
381 .await;
382 client
383 .resolvers
384 .instruments_by_id
385 .insert("u2".to_string(), "NVDA".to_string())
386 .await;
387 let resolved = client
388 .resolve_symbols(&["u1".to_string(), "u2".to_string()])
389 .await
390 .unwrap();
391 assert_eq!(resolved.get("u1").map(String::as_str), Some("AAPL"));
392 assert_eq!(resolved.get("u2").map(String::as_str), Some("NVDA"));
393 assert_eq!(resolved.len(), 2);
394 }
395
396 #[test]
397 fn historical_bounds_validation_trading_with_day_span() {
398 let opts = HistoricalOpts {
399 interval: HistoricalInterval::FiveMinute,
400 span: HistoricalSpan::Month,
401 bounds: HistoricalBounds::Trading,
402 };
403 let is_invalid = matches!(
404 opts.bounds,
405 HistoricalBounds::Extended | HistoricalBounds::Trading
406 ) && !matches!(opts.span, HistoricalSpan::Day);
407 assert!(is_invalid);
408 }
409
410 #[test]
411 fn fundamentals_deserializes_full_snapshot() {
412 let json = r#"{
413 "open": "150.00",
414 "high": "155.00",
415 "low": "149.00",
416 "volume": "1200000",
417 "market_cap": "2500000000000.00",
418 "pe_ratio": "28.50",
419 "dividend_yield": "0.55",
420 "sector": "Technology",
421 "industry": "Consumer Electronics",
422 "symbol": "AAPL",
423 "ceo": "Tim Cook",
424 "num_employees": 164000,
425 "year_founded": 1976
426 }"#;
427 let fund: Fundamentals = serde_json::from_str(json).unwrap();
428 assert_eq!(fund.symbol.as_deref(), Some("AAPL"));
429 assert_eq!(fund.sector.as_deref(), Some("Technology"));
430 assert_eq!(fund.pe_ratio.as_deref(), Some("28.50"));
431 assert_eq!(fund.num_employees, Some(164000));
432 assert_eq!(fund.year_founded, Some(1976));
433 }
434
435 #[test]
436 fn fundamentals_handles_missing_fields() {
437 let json = r#"{ "symbol": "XYZ" }"#;
438 let fund: Fundamentals = serde_json::from_str(json).unwrap();
439 assert_eq!(fund.symbol.as_deref(), Some("XYZ"));
440 assert!(fund.pe_ratio.is_none());
441 assert!(fund.market_cap.is_none());
442 }
443
444 #[test]
445 fn stock_quote_deserializes_real_api_shape() {
446 let json = r#"{
447 "ask_price": "261.500000",
448 "ask_size": 1108,
449 "venue_ask_time": "2026-03-11T00:00:00.231504626Z",
450 "bid_price": "258.360000",
451 "bid_size": 38,
452 "venue_bid_time": "2026-03-11T00:00:00.231504626Z",
453 "last_trade_price": "260.720000",
454 "venue_last_trade_time": "2026-03-10T19:59:59.976239327Z",
455 "last_extended_hours_trade_price": "261.200000",
456 "last_non_reg_trade_price": "261.200000",
457 "venue_last_non_reg_trade_time": "2026-03-10T23:50:47.288714718Z",
458 "previous_close": "259.880000",
459 "adjusted_previous_close": "259.880000",
460 "previous_close_date": "2026-03-09",
461 "symbol": "AAPL",
462 "trading_halted": false,
463 "has_traded": true,
464 "last_trade_price_source": "nls",
465 "last_non_reg_trade_price_source": "nls",
466 "updated_at": "2026-03-11T00:00:00Z",
467 "instrument": "https://api.robinhood.com/instruments/450dfc6d-5510-4d40-abfb-f633b7d9be3e/",
468 "instrument_id": "450dfc6d-5510-4d40-abfb-f633b7d9be3e",
469 "state": "active"
470 }"#;
471 let quote: StockQuote = serde_json::from_str(json).unwrap();
472 assert_eq!(quote.symbol.as_deref(), Some("AAPL"));
473 assert_eq!(
474 quote.instrument_id.as_deref(),
475 Some("450dfc6d-5510-4d40-abfb-f633b7d9be3e")
476 );
477 assert_eq!(quote.state.as_deref(), Some("active"));
478 assert_eq!(
479 quote.last_non_reg_trade_price.as_deref(),
480 Some("261.200000")
481 );
482 assert_eq!(
483 quote.last_non_reg_trade_price_source.as_deref(),
484 Some("nls")
485 );
486 assert!(quote.venue_ask_time.is_some());
487 assert!(quote.venue_bid_time.is_some());
488 assert!(quote.venue_last_trade_time.is_some());
489 assert!(quote.venue_last_non_reg_trade_time.is_some());
490 }
491
492 #[test]
493 fn fundamentals_deserializes_real_api_shape() {
494 let json = r#"{
495 "open": "257.740000",
496 "high": "262.480000",
497 "low": "256.950000",
498 "volume": "30587286.000000",
499 "overnight_volume": "0.000000",
500 "bounds": "regular",
501 "market_date": "2026-03-10",
502 "average_volume_2_weeks": "41821391.854018",
503 "average_volume": "41821391.854018",
504 "average_volume_30_days": "45020359.323600",
505 "high_52_weeks": "288.620000",
506 "high_52_weeks_date": "2025-12-03",
507 "dividend_yield": "0.400185",
508 "float": "14664480994.799999",
509 "low_52_weeks": "169.210100",
510 "low_52_weeks_date": "2025-04-08",
511 "market_cap": "3827666801057.393066",
512 "pb_ratio": "43.326200",
513 "pe_ratio": "32.880387",
514 "shares_outstanding": "14681139924.276590",
515 "description": "Apple, Inc.",
516 "instrument": "https://api.robinhood.com/instruments/450dfc6d/",
517 "ceo": "Timothy Donald Cook",
518 "headquarters_city": "Cupertino",
519 "headquarters_state": "California",
520 "sector": "Electronic Technology",
521 "industry": "Telecommunications Equipment",
522 "num_employees": 166000,
523 "year_founded": 1976,
524 "payable_date": "2026-02-12",
525 "ex_dividend_date": "2026-02-09",
526 "financial_status_indicator": "CC0",
527 "financial_status_description": ""
528 }"#;
529 let fund: Fundamentals = serde_json::from_str(json).unwrap();
530 assert_eq!(fund.overnight_volume.as_deref(), Some("0.000000"));
531 assert_eq!(fund.bounds.as_deref(), Some("regular"));
532 assert_eq!(fund.market_date.as_deref(), Some("2026-03-10"));
533 assert_eq!(
534 fund.average_volume_30_days.as_deref(),
535 Some("45020359.323600")
536 );
537 assert_eq!(fund.high_52_weeks_date.as_deref(), Some("2025-12-03"));
538 assert_eq!(fund.low_52_weeks_date.as_deref(), Some("2025-04-08"));
539 assert_eq!(fund.payable_date.as_deref(), Some("2026-02-12"));
540 assert_eq!(fund.ex_dividend_date.as_deref(), Some("2026-02-09"));
541 assert_eq!(fund.financial_status_indicator.as_deref(), Some("CC0"));
542 assert_eq!(fund.num_employees, Some(166000));
543 }
544
545 #[test]
546 fn batch_ids_response_deserializes_full_instrument() {
547 use crate::models::stock::Instrument;
548 use crate::pagination::ResultsResponse;
549 let json = r#"{
556 "next": null,
557 "previous": null,
558 "results": [
559 {
560 "id": "450dfc6d-5510-4d40-abfb-f633b7d9be3e",
561 "url": "https://api.robinhood.com/instruments/450dfc6d-5510-4d40-abfb-f633b7d9be3e/",
562 "symbol": "AAPL",
563 "simple_name": "Apple",
564 "name": "Apple Inc. Common Stock",
565 "tradeable": true,
566 "bloomberg_unique": "EQ0010169500001000",
567 "day_trade_ratio": "0.2500",
568 "list_date": "1990-01-02",
569 "state": "active"
570 }
571 ]
572 }"#;
573 let resp: ResultsResponse<Instrument> = serde_json::from_str(json).unwrap();
574 assert_eq!(resp.results.len(), 1);
575 assert_eq!(
576 resp.results[0].id.as_deref(),
577 Some("450dfc6d-5510-4d40-abfb-f633b7d9be3e")
578 );
579 assert_eq!(resp.results[0].symbol.as_deref(), Some("AAPL"));
580 }
581
582 #[test]
583 fn batch_ids_response_skips_null_entries() {
584 let json = r#"{
591 "next": null,
592 "previous": null,
593 "results": [
594 {
595 "id": "450dfc6d-5510-4d40-abfb-f633b7d9be3e",
596 "symbol": "AAPL",
597 "state": "active"
598 },
599 null,
600 {
601 "id": "18226051-6bfa-4c56-bd9a-d7575f0245c1",
602 "symbol": "VTI",
603 "state": "active"
604 }
605 ]
606 }"#;
607 let resp: InstrumentBatchResponse = serde_json::from_str(json).unwrap();
608 let resolved: Vec<&Instrument> = resp.results.iter().flatten().collect();
609 assert_eq!(resolved.len(), 2);
610 assert_eq!(resolved[0].symbol.as_deref(), Some("AAPL"));
611 assert_eq!(resolved[1].symbol.as_deref(), Some("VTI"));
612 }
613
614 #[test]
615 fn instrument_deserializes_real_api_shape() {
616 let json = r#"{
617 "id": "450dfc6d-5510-4d40-abfb-f633b7d9be3e",
618 "url": "https://api.robinhood.com/instruments/450dfc6d-5510-4d40-abfb-f633b7d9be3e/",
619 "quote": "https://api.robinhood.com/quotes/AAPL/",
620 "fundamentals": "https://api.robinhood.com/fundamentals/AAPL/",
621 "splits": "https://api.robinhood.com/instruments/450dfc6d/splits/",
622 "state": "active",
623 "market": "https://api.robinhood.com/markets/XNAS/",
624 "simple_name": "Apple",
625 "name": "Apple Inc. Common Stock",
626 "tradeable": true,
627 "tradability": "tradable",
628 "symbol": "AAPL",
629 "bloomberg_unique": "EQ0010169500001000",
630 "margin_initial_ratio": "0.5000",
631 "maintenance_ratio": "0.2500",
632 "country": "US",
633 "day_trade_ratio": "0.2500",
634 "list_date": "1990-01-02",
635 "min_tick_size": null,
636 "type": "stock",
637 "tradable_chain_id": "7dd906e5-7d4b-4161-a3fe-2c3b62038482",
638 "rhs_tradability": "tradable",
639 "affiliate_tradability": "tradable",
640 "fractional_tradability": "tradable",
641 "short_selling_tradability": "tradable",
642 "default_collar_fraction": "0.05",
643 "is_spac": false,
644 "is_test": false,
645 "extended_hours_fractional_tradability": false,
646 "all_day_tradability": "tradable",
647 "notional_estimated_quantity_decimals": 6,
648 "tax_security_type": "stock",
649 "car_required": false,
650 "high_risk_maintenance_ratio": "0.2500",
651 "low_risk_maintenance_ratio": "0.2500",
652 "default_preset_percent_limit": "0.02",
653 "affiliate": "rhf",
654 "account_type_tradabilities": [
655 {
656 "account_type": "individual",
657 "account_type_tradability": "tradable"
658 }
659 ],
660 "issuer_type": "third_party"
661 }"#;
662 let inst: Instrument = serde_json::from_str(json).unwrap();
663 assert_eq!(inst.symbol.as_deref(), Some("AAPL"));
664 assert_eq!(inst.state.as_deref(), Some("active"));
665 assert_eq!(inst.bloomberg_unique.as_deref(), Some("EQ0010169500001000"));
666 assert_eq!(inst.margin_initial_ratio.as_deref(), Some("0.5000"));
667 assert_eq!(inst.day_trade_ratio.as_deref(), Some("0.2500"));
668 assert_eq!(inst.list_date.as_deref(), Some("1990-01-02"));
669 assert_eq!(inst.rhs_tradability.as_deref(), Some("tradable"));
670 assert_eq!(inst.short_selling_tradability.as_deref(), Some("tradable"));
671 assert_eq!(inst.is_spac, Some(false));
672 assert_eq!(inst.is_test, Some(false));
673 assert_eq!(inst.extended_hours_fractional_tradability, Some(false));
674 assert_eq!(inst.notional_estimated_quantity_decimals, Some(6));
675 assert_eq!(inst.tax_security_type.as_deref(), Some("stock"));
676 assert_eq!(inst.car_required, Some(false));
677 assert_eq!(inst.issuer_type.as_deref(), Some("third_party"));
678 let tradabilities = inst.account_type_tradabilities.unwrap();
679 assert_eq!(tradabilities.len(), 1);
680 assert_eq!(tradabilities[0].account_type.as_deref(), Some("individual"));
681 }
682}