Skip to main content

rhood_core/endpoints/
stocks.rs

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/// Batch `/instruments/?ids=` response, tolerant of `null` array entries.
11///
12/// Robinhood returns a literal `null` in `results` for delisted or otherwise
13/// unresolvable ids in the requested batch. Deserializing those positions into
14/// `Vec<Instrument>` would fail the entire batch (and, via the best-effort
15/// enrichment layer, zero out symbols for *every* item in the request). Using
16/// `Vec<Option<Instrument>>` keeps the resolvable instruments and drops the
17/// nulls.
18#[derive(serde::Deserialize)]
19struct InstrumentBatchResponse {
20    results: Vec<Option<Instrument>>,
21}
22
23impl RobinhoodClient {
24    /// Fetches real-time stock quotes for one or more ticker symbols.
25    ///
26    /// Symbols are uppercased before the request. Results are filtered to
27    /// include only quotes that contain a valid symbol field.
28    ///
29    /// # Errors
30    ///
31    /// Returns an error if the HTTP request fails or the response cannot be
32    /// deserialized.
33    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), &params)
42            .await?;
43        Ok(resp
44            .results
45            .into_iter()
46            .filter(|quote| quote.symbol.is_some())
47            .collect())
48    }
49
50    /// Returns the latest trade price for each requested symbol.
51    ///
52    /// Prefers the extended-hours trade price when available; otherwise falls
53    /// back to the last regular-session trade price. Each entry in the
54    /// returned vector is a `(symbol, price)` tuple.
55    ///
56    /// # Errors
57    ///
58    /// Returns an error if the underlying quote request fails.
59    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    /// Fetches fundamental data (market cap, P/E ratio, dividend yield, etc.)
74    /// for one or more ticker symbols.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if the HTTP request fails or the response cannot be
79    /// deserialized.
80    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), &params)
89            .await?;
90        Ok(resp.results)
91    }
92
93    /// Fetches historical price data (OHLCV candles) for one or more symbols.
94    ///
95    /// The `opts` parameter controls the candle interval, time span, and
96    /// session bounds. Extended and trading bounds are only valid with a
97    /// day span; other combinations return an error.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`RhoodError::InvalidParameter`] if extended or trading bounds
102    /// are used with a non-day span. Also returns an error on HTTP or
103    /// deserialization failures.
104    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), &params)
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    /// Looks up a Robinhood instrument by its ticker symbol.
147    ///
148    /// Returns `Ok(None)` when the symbol does not match any known instrument.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if the HTTP request fails or the response cannot be
153    /// deserialized.
154    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), &params)
159            .await?;
160        Ok(resp.results.into_iter().next())
161    }
162
163    /// Cached wrapper around [`get_instrument_by_symbol`](Self::get_instrument_by_symbol).
164    ///
165    /// Returns an [`Arc<Instrument>`] shared with other callers requesting the
166    /// same symbol during the TTL configured on the resolver cache. When
167    /// caching is disabled (`CacheConfig::enabled = false`), every call hits
168    /// upstream and nothing is inserted into the cache.
169    ///
170    /// Errors are not cached: a failed lookup for one caller does not taint
171    /// concurrent callers requesting the same symbol. Successful hits also
172    /// populate the reverse `uuid → symbol` map used by
173    /// [`resolve_symbols`](Self::resolve_symbols).
174    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    /// Looks up a Robinhood index instrument by its symbol (e.g., "SPX").
200    ///
201    /// Returns `Ok(None)` when the symbol does not match any known index.
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if the HTTP request fails or the response cannot be
206    /// deserialized.
207    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), &params)
212            .await?;
213        Ok(resp.results.into_iter().next())
214    }
215
216    /// Cached wrapper around [`get_index_instrument`](Self::get_index_instrument).
217    ///
218    /// Returns an [`Arc<IndexInstrument>`] shared with other callers during
219    /// the configured TTL. Caching is skipped entirely when
220    /// `CacheConfig::enabled = false`.
221    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    /// Resolves a batch of instrument UUIDs to ticker symbols.
244    ///
245    /// Consults the resolver cache's `uuid → symbol` map first; only uncached
246    /// UUIDs are sent upstream. Misses are chunked into batches of
247    /// [`CacheConfig::enrichment_batch_size`](crate::config::CacheConfig::enrichment_batch_size)
248    /// to stay under Robinhood's query-string limits, and each chunk is sent
249    /// as a single `?ids=uuid1,uuid2,...` request to `/instruments/`. Results
250    /// are written back to the cache when enabled.
251    ///
252    /// The returned map contains only UUIDs that resolve to an instrument with
253    /// both an id and a symbol; any UUID whose instrument payload lacks either
254    /// field is omitted from the map rather than producing an error.
255    ///
256    /// # Errors
257    ///
258    /// Returns an error if any chunked upstream request fails.
259    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), &params)
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    /// Fetches real-time market data for an index symbol.
303    ///
304    /// Resolves the symbol to its index ID, then queries the index-specific
305    /// market data endpoint.
306    ///
307    /// # Errors
308    ///
309    /// Returns [`RhoodError::InvalidSymbol`] if the index is not found.
310    /// Also returns an error on HTTP or deserialization failures.
311    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        // Backfill the symbol from the instrument if the API omits it
324        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        // Real shape of GET /instruments/?ids=a,b : a paginated envelope whose
550        // results are FULL instrument objects with many fields beyond what
551        // `Instrument` declares. This must still deserialize and expose id +
552        // symbol (the contract `resolve_symbols` relies on). Guards against a
553        // future `deny_unknown_fields` or type change reintroducing null
554        // enrichment.
555        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        // Robinhood's GET /instruments/?ids=a,b returns a literal `null` in the
585        // `results` array for delisted/invalid ids (observed live for a closed
586        // zero-quantity position). A plain `Vec<Instrument>` fails the WHOLE
587        // batch parse on that null, zeroing out symbol enrichment for every
588        // position in the request. The batch response must tolerate nulls and
589        // keep the resolvable instruments.
590        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}