Skip to main content

rhood_core/endpoints/
options.rs

1use crate::api::paths;
2use crate::client::RobinhoodClient;
3use crate::models::option::*;
4use crate::pagination::ResultsResponse;
5use crate::{Result, RhoodError};
6
7/// Index symbols supported for index options trading.
8pub const INDEX_SYMBOLS: &[&str] = &["SPX", "NDX", "VIX", "RUT", "XSP"];
9
10/// Maps an index symbol to the chain symbol used for weekly option contract lookups.
11///
12/// Most index symbols have weekly variants with different suffixes.
13/// Non-index symbols pass through unchanged.
14pub fn index_chain_symbol(symbol: &str) -> &str {
15    match symbol {
16        "SPX" => "SPXW",
17        "NDX" => "NDXP",
18        "VIX" => "VIXW",
19        "RUT" => "RUTW",
20        _ => symbol,
21    }
22}
23
24impl RobinhoodClient {
25    /// Fetches the option chain for a given stock symbol.
26    ///
27    /// Resolves the symbol to its instrument and then retrieves the
28    /// associated tradable chain.
29    ///
30    /// # Errors
31    ///
32    /// Returns [`RhoodError::InvalidSymbol`] if the symbol has no tradable
33    /// option chain. Also returns an error on HTTP or deserialization failures.
34    pub async fn get_option_chain(&self, symbol: &str) -> Result<OptionChain> {
35        let instrument = self.cached_instrument(symbol).await?;
36        let chain_id = instrument
37            .and_then(|instrument| instrument.tradable_chain_id.clone())
38            .ok_or_else(|| RhoodError::InvalidSymbol(symbol.to_string()))?;
39        let url = format!("{}{chain_id}/", self.api_url(paths::OPTION_CHAINS));
40        self.get(&url).await
41    }
42
43    /// Searches for option contracts matching the specified criteria.
44    ///
45    /// Filters by symbol, expiration date, option type (`"call"` or `"put"`),
46    /// and optionally a specific strike price. Only active contracts are
47    /// returned.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`RhoodError::InvalidSymbol`] if the symbol has no tradable
52    /// option chain. Also returns an error on HTTP or deserialization failures.
53    pub async fn find_options(
54        &self,
55        symbol: &str,
56        expiration_date: &str,
57        option_type: &str,
58        strike_price: Option<&str>,
59    ) -> Result<Vec<OptionInstrument>> {
60        let instrument = self.cached_instrument(symbol).await?;
61        let chain_id = instrument
62            .and_then(|instrument| instrument.tradable_chain_id.clone())
63            .ok_or_else(|| RhoodError::InvalidSymbol(symbol.to_string()))?;
64        let mut params: Vec<(&str, &str)> = vec![
65            ("chain_id", &chain_id),
66            ("expiration_dates", expiration_date),
67            ("type", option_type),
68            ("state", "active"),
69        ];
70        if let Some(strike) = strike_price {
71            params.push(("strike_price", strike));
72        }
73        self.get_paginated(&self.api_url(paths::OPTION_INSTRUMENTS), &params)
74            .await
75    }
76
77    /// Fetches all option positions, including those with a zero quantity.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if the HTTP request fails or the response cannot be
82    /// deserialized.
83    pub async fn get_option_positions(&self) -> Result<Vec<OptionPosition>> {
84        self.get_paginated(&self.api_url(paths::OPTION_POSITIONS), &[])
85            .await
86    }
87
88    /// Fetches only open option positions (quantity greater than zero).
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the underlying positions request fails.
93    pub async fn get_open_option_positions(&self) -> Result<Vec<OptionPosition>> {
94        let positions = self.get_option_positions().await?;
95        Ok(positions
96            .into_iter()
97            .filter(|position| {
98                position
99                    .quantity
100                    .as_deref()
101                    .and_then(|quantity| quantity.parse::<f64>().ok())
102                    .is_some_and(|quantity| quantity > 0.0)
103            })
104            .collect())
105    }
106
107    /// Fetches live market data for specific option contracts.
108    ///
109    /// Resolves each [`OptionContractSpec`] to its instrument URL via
110    /// [`find_options`](Self::find_options), then fetches bid/ask, Greeks,
111    /// volume, open interest, and probability data in a single batched request
112    /// to `/marketdata/options/`.
113    ///
114    /// # Errors
115    ///
116    /// Returns [`RhoodError::InvalidParameter`] if any contract spec does not
117    /// match an active option instrument. Also returns an error on HTTP or
118    /// deserialization failures.
119    pub async fn get_option_market_data(
120        &self,
121        symbol: &str,
122        contracts: &[OptionContractSpec<'_>],
123    ) -> Result<Vec<OptionMarketData>> {
124        if contracts.is_empty() {
125            return Ok(Vec::new());
126        }
127
128        let mut instrument_urls: Vec<String> = Vec::with_capacity(contracts.len());
129
130        for spec in contracts {
131            let results = self
132                .find_options(
133                    symbol,
134                    spec.expiration_date,
135                    spec.option_type,
136                    Some(spec.strike_price),
137                )
138                .await?;
139
140            let instrument = results.into_iter().next().ok_or_else(|| {
141                RhoodError::InvalidParameter(format!(
142                    "No contract found for {} ${} {} {}",
143                    symbol.to_uppercase(),
144                    spec.strike_price,
145                    spec.option_type,
146                    spec.expiration_date,
147                ))
148            })?;
149
150            let url = instrument.url.ok_or_else(|| {
151                RhoodError::InvalidParameter(format!(
152                    "Option instrument for {} ${} {} {} has no URL",
153                    symbol.to_uppercase(),
154                    spec.strike_price,
155                    spec.option_type,
156                    spec.expiration_date,
157                ))
158            })?;
159
160            instrument_urls.push(url);
161        }
162
163        self.get_option_market_data_by_instrument_urls(&instrument_urls)
164            .await
165    }
166
167    /// Fetches live market data for option instrument URLs.
168    ///
169    /// Sends the supplied URLs directly to `/marketdata/options/` without
170    /// performing option-instrument discovery. Results are identified by their
171    /// existing [`OptionMarketData::instrument`] field; their order is not
172    /// guaranteed to match the input order.
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if the HTTP request fails or the response cannot be
177    /// deserialized.
178    pub async fn get_option_market_data_by_instrument_urls(
179        &self,
180        instrument_urls: &[String],
181    ) -> Result<Vec<OptionMarketData>> {
182        if instrument_urls.is_empty() {
183            return Ok(Vec::new());
184        }
185
186        let joined_instruments = instrument_urls.join(",");
187        let params = [("instruments", joined_instruments.as_str())];
188        let resp: ResultsResponse<OptionMarketData> = self
189            .get_with_params(&self.api_url(paths::OPTION_MARKET_DATA), &params)
190            .await?;
191        Ok(resp.results)
192    }
193
194    /// Fetches the option chain for an index symbol (e.g., "SPX").
195    ///
196    /// Resolves the symbol to its index instrument, picks the first
197    /// `tradable_chain_ids` entry, and retrieves the chain metadata.
198    ///
199    /// # Errors
200    ///
201    /// Returns [`RhoodError::InvalidSymbol`] if the index has no tradable
202    /// option chain. Also returns an error on HTTP or deserialization failures.
203    pub async fn get_index_option_chain(&self, symbol: &str) -> Result<OptionChain> {
204        let index = self
205            .cached_index_instrument(symbol)
206            .await?
207            .ok_or_else(|| RhoodError::InvalidSymbol(symbol.to_string()))?;
208        let chain_id = index
209            .tradable_chain_ids
210            .clone()
211            .and_then(|mut ids| {
212                ids.sort();
213                ids.into_iter().next()
214            })
215            .ok_or_else(|| RhoodError::InvalidSymbol(symbol.to_string()))?;
216        let url = format!("{}{chain_id}/", self.api_url(paths::OPTION_CHAINS));
217        self.get(&url).await
218    }
219
220    /// Searches for index option contracts matching the specified criteria.
221    ///
222    /// Applies the weekly suffix mapping (e.g., SPX -> SPXW) and resolves
223    /// the chain ID from the index instrument.
224    ///
225    /// # Errors
226    ///
227    /// Returns [`RhoodError::InvalidSymbol`] if the index has no tradable
228    /// option chain. Also returns an error on HTTP or deserialization failures.
229    pub async fn find_index_options(
230        &self,
231        symbol: &str,
232        expiration_date: &str,
233        option_type: OptionType,
234        strike_price: Option<&str>,
235    ) -> Result<Vec<OptionInstrument>> {
236        let index = self
237            .cached_index_instrument(symbol)
238            .await?
239            .ok_or_else(|| RhoodError::InvalidSymbol(symbol.to_string()))?;
240        let chain_id = index
241            .tradable_chain_ids
242            .clone()
243            .and_then(|mut ids| {
244                ids.sort();
245                ids.into_iter().next()
246            })
247            .ok_or_else(|| RhoodError::InvalidSymbol(symbol.to_string()))?;
248        let chain_symbol = index_chain_symbol(symbol);
249        let option_type_string = option_type.to_string();
250        let mut params: Vec<(&str, &str)> = vec![
251            ("chain_id", &chain_id),
252            ("chain_symbol", chain_symbol),
253            ("expiration_dates", expiration_date),
254            ("type", option_type_string.as_str()),
255            ("state", "active"),
256        ];
257        if let Some(strike) = strike_price {
258            params.push(("strike_price", strike));
259        }
260        self.get_paginated(&self.api_url(paths::OPTION_INSTRUMENTS), &params)
261            .await
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::config::RhoodConfig;
269    use crate::models::option::{OptionContractSpec, OptionMarketData, OptionPosition};
270    use crate::models::order::OptionOrder;
271    use crate::models::stock::{IndexInstrument, IndexQuoteWrapper};
272    use secrecy::SecretString;
273    use wiremock::matchers::{method, path, query_param, query_param_is_missing};
274    use wiremock::{Mock, MockServer, ResponseTemplate};
275
276    async fn client_for_server(base_url: &str) -> (tempfile::TempDir, RobinhoodClient) {
277        let dir = tempfile::tempdir().unwrap();
278        let mut config = RhoodConfig::default();
279        config.auth.token_cache_path = dir
280            .path()
281            .join("nonexistent-token.json")
282            .to_str()
283            .unwrap()
284            .to_string();
285        config.api.base_url = base_url.to_string();
286        let client = RobinhoodClient::with_config(config).unwrap();
287        client
288            .inject_test_auth(
289                SecretString::from("access-token"),
290                "Bearer".to_string(),
291                SecretString::from("refresh-token"),
292            )
293            .await;
294        (dir, client)
295    }
296
297    async fn mount_equity_option_lookup(server: &MockServer) {
298        Mock::given(method("GET"))
299            .and(path("/instruments/"))
300            .and(query_param("symbol", "AAPL"))
301            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
302                "results": [{"symbol": "AAPL", "tradable_chain_id": "chain-aapl"}]
303            })))
304            .expect(1)
305            .mount(server)
306            .await;
307    }
308
309    fn option_search_response() -> ResponseTemplate {
310        ResponseTemplate::new(200).set_body_json(serde_json::json!({
311            "results": [{
312                "chain_id": "chain-aapl",
313                "chain_symbol": "AAPL",
314                "expiration_date": "2026-06-18",
315                "id": "call-310",
316                "state": "active",
317                "strike_price": "310.0000",
318                "type": "call"
319            }],
320            "next": null,
321            "previous": null
322        }))
323    }
324
325    #[tokio::test]
326    async fn find_options_omits_optional_strike_filter() {
327        let server = MockServer::start().await;
328        mount_equity_option_lookup(&server).await;
329        Mock::given(method("GET"))
330            .and(path("/options/instruments/"))
331            .and(query_param("chain_id", "chain-aapl"))
332            .and(query_param("expiration_dates", "2026-06-18"))
333            .and(query_param("type", "call"))
334            .and(query_param("state", "active"))
335            .and(query_param_is_missing("strike_price"))
336            .respond_with(option_search_response())
337            .expect(1)
338            .mount(&server)
339            .await;
340        let (_dir, client) = client_for_server(&server.uri()).await;
341
342        let options = client
343            .find_options("AAPL", "2026-06-18", "call", None)
344            .await
345            .unwrap();
346
347        assert_eq!(options.len(), 1);
348        assert_eq!(options[0].strike_price.as_deref(), Some("310.0000"));
349        server.verify().await;
350    }
351
352    #[tokio::test]
353    async fn find_options_includes_optional_strike_filter() {
354        let server = MockServer::start().await;
355        mount_equity_option_lookup(&server).await;
356        Mock::given(method("GET"))
357            .and(path("/options/instruments/"))
358            .and(query_param("chain_id", "chain-aapl"))
359            .and(query_param("expiration_dates", "2026-06-18"))
360            .and(query_param("type", "call"))
361            .and(query_param("state", "active"))
362            .and(query_param("strike_price", "310.0000"))
363            .respond_with(option_search_response())
364            .expect(1)
365            .mount(&server)
366            .await;
367        let (_dir, client) = client_for_server(&server.uri()).await;
368
369        let options = client
370            .find_options("AAPL", "2026-06-18", "call", Some("310.0000"))
371            .await
372            .unwrap();
373
374        assert_eq!(options.len(), 1);
375        assert_eq!(options[0].id.as_deref(), Some("call-310"));
376        server.verify().await;
377    }
378
379    #[tokio::test]
380    async fn option_market_data_by_instrument_urls_queries_market_data_without_discovery() {
381        let server = MockServer::start().await;
382        let instrument_urls = vec![
383            "https://api.robinhood.com/options/instruments/held-call/".to_string(),
384            "https://api.robinhood.com/options/instruments/held-put/".to_string(),
385        ];
386        Mock::given(method("GET"))
387            .and(path("/marketdata/options/"))
388            .and(query_param("instruments", instrument_urls.join(",")))
389            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
390                "results": [{
391                    "instrument": instrument_urls[0],
392                    "instrument_id": "held-call",
393                    "bid_price": "1.20",
394                    "ask_price": "1.30"
395                }],
396                "next": null,
397                "previous": null
398            })))
399            .expect(1)
400            .mount(&server)
401            .await;
402        let (_dir, client) = client_for_server(&server.uri()).await;
403
404        let quotes = client
405            .get_option_market_data_by_instrument_urls(&instrument_urls)
406            .await
407            .unwrap();
408
409        assert_eq!(quotes.len(), 1);
410        assert_eq!(
411            quotes[0].instrument.as_deref(),
412            Some(instrument_urls[0].as_str())
413        );
414        assert_eq!(quotes[0].instrument_id.as_deref(), Some("held-call"));
415        let requests = server.received_requests().await.unwrap();
416        assert_eq!(requests.len(), 1, "URL mode must make exactly one request");
417        assert_eq!(requests[0].url.path(), "/marketdata/options/");
418        server.verify().await;
419    }
420
421    #[test]
422    fn option_contract_spec_fields_pass_through() {
423        let spec = OptionContractSpec {
424            strike_price: "50.0000",
425            expiration_date: "2026-04-02",
426            option_type: "put",
427        };
428        assert_eq!(spec.strike_price, "50.0000");
429        assert_eq!(spec.expiration_date, "2026-04-02");
430        assert_eq!(spec.option_type, "put");
431    }
432
433    #[test]
434    fn option_market_data_deserializes_full_snapshot() {
435        let json = r#"{
436            "instrument": "https://api.robinhood.com/options/instruments/abc/",
437            "instrument_id": "abc",
438            "bid_price": "1.23",
439            "ask_price": "1.35",
440            "last_trade_price": "1.30",
441            "mark_price": "1.29",
442            "break_even_price": "48.71",
443            "adjusted_mark_price": "1.29",
444            "previous_close_price": "1.40",
445            "high_price": "1.50",
446            "low_price": "1.10",
447            "delta": "-0.3500",
448            "gamma": "0.0800",
449            "theta": "-0.0500",
450            "vega": "0.1200",
451            "rho": "-0.0100",
452            "implied_volatility": "0.4500",
453            "volume": 1204,
454            "open_interest": 8923,
455            "chance_of_profit_long": "0.35",
456            "chance_of_profit_short": "0.65",
457            "updated_at": "2026-04-01T16:00:00Z"
458        }"#;
459        let data: OptionMarketData = serde_json::from_str(json).unwrap();
460        assert_eq!(data.bid_price.as_deref(), Some("1.23"));
461        assert_eq!(data.ask_price.as_deref(), Some("1.35"));
462        assert_eq!(data.delta.as_deref(), Some("-0.3500"));
463        assert_eq!(data.volume, Some(1204));
464        assert_eq!(data.open_interest, Some(8923));
465        assert_eq!(data.chance_of_profit_long.as_deref(), Some("0.35"));
466    }
467
468    #[test]
469    fn option_market_data_handles_missing_fields() {
470        let json = r#"{
471            "bid_price": "1.23",
472            "ask_price": "1.35"
473        }"#;
474        let data: OptionMarketData = serde_json::from_str(json).unwrap();
475        assert_eq!(data.bid_price.as_deref(), Some("1.23"));
476        assert!(data.delta.is_none());
477        assert!(data.volume.is_none());
478        assert!(data.instrument_id.is_none());
479    }
480
481    #[test]
482    fn option_position_deserializes_full_snapshot() {
483        let json = r#"{
484            "account": "https://api.robinhood.com/accounts/ABC123/",
485            "average_price": "1.5400",
486            "chain_id": "chain-001",
487            "chain_symbol": "AAPL",
488            "id": "pos-001",
489            "option": "https://api.robinhood.com/options/instruments/opt-001/",
490            "quantity": "2.0000",
491            "type": "long",
492            "created_at": "2026-03-15T10:00:00Z",
493            "updated_at": "2026-03-31T14:00:00Z"
494        }"#;
495        let pos: OptionPosition = serde_json::from_str(json).unwrap();
496        assert_eq!(pos.chain_symbol.as_deref(), Some("AAPL"));
497        assert_eq!(pos.quantity.as_deref(), Some("2.0000"));
498        assert_eq!(pos.average_price.as_deref(), Some("1.5400"));
499        assert_eq!(pos.position_type.as_deref(), Some("long"));
500        assert_eq!(pos.chain_id.as_deref(), Some("chain-001"));
501        assert_eq!(pos.id.as_deref(), Some("pos-001"));
502    }
503
504    #[test]
505    fn option_position_handles_missing_fields() {
506        let json = r#"{
507            "chain_symbol": "TSLA",
508            "quantity": "1.0000",
509            "type": "short"
510        }"#;
511        let pos: OptionPosition = serde_json::from_str(json).unwrap();
512        assert_eq!(pos.chain_symbol.as_deref(), Some("TSLA"));
513        assert_eq!(pos.position_type.as_deref(), Some("short"));
514        assert!(pos.average_price.is_none());
515        assert!(pos.account.is_none());
516        assert!(pos.id.is_none());
517    }
518
519    #[test]
520    fn option_position_serializes_round_trip() {
521        let json = r#"{
522            "account": null,
523            "average_price": "3.2000",
524            "chain_id": "chain-002",
525            "chain_symbol": "NKE",
526            "id": "pos-002",
527            "option": "https://api.robinhood.com/options/instruments/opt-002/",
528            "quantity": "5.0000",
529            "type": "long",
530            "created_at": "2026-03-20T09:00:00Z",
531            "updated_at": "2026-03-30T16:00:00Z"
532        }"#;
533        let pos: OptionPosition = serde_json::from_str(json).unwrap();
534        let serialized = serde_json::to_string(&pos).unwrap();
535        let round_tripped: OptionPosition = serde_json::from_str(&serialized).unwrap();
536        assert_eq!(round_tripped.chain_symbol.as_deref(), Some("NKE"));
537        assert_eq!(round_tripped.quantity.as_deref(), Some("5.0000"));
538        assert_eq!(round_tripped.position_type.as_deref(), Some("long"));
539    }
540
541    #[test]
542    fn option_order_deserializes_full_snapshot() {
543        let json = r#"{
544            "id": "opt-order-001",
545            "chain_id": "chain-001",
546            "chain_symbol": "AAPL",
547            "direction": "debit",
548            "premium": "1.54",
549            "price": "1.54",
550            "quantity": "2.0000",
551            "state": "filled",
552            "type": "limit",
553            "time_in_force": "gtc",
554            "cancel_url": null,
555            "created_at": "2026-03-31T10:00:00Z",
556            "updated_at": "2026-03-31T10:01:00Z"
557        }"#;
558        let order: OptionOrder = serde_json::from_str(json).unwrap();
559        assert_eq!(order.id.as_deref(), Some("opt-order-001"));
560        assert_eq!(order.chain_symbol.as_deref(), Some("AAPL"));
561        assert_eq!(order.direction.as_deref(), Some("debit"));
562        assert_eq!(order.state.as_deref(), Some("filled"));
563        assert!(order.cancel_url.is_none());
564    }
565
566    #[test]
567    fn option_order_open_has_cancel_url() {
568        let json = r#"{
569            "id": "opt-order-002",
570            "chain_symbol": "NKE",
571            "state": "queued",
572            "cancel_url": "https://api.robinhood.com/options/orders/opt-order-002/cancel/"
573        }"#;
574        let order: OptionOrder = serde_json::from_str(json).unwrap();
575        assert!(order.cancel_url.is_some());
576    }
577
578    #[test]
579    fn index_instrument_deserializes() {
580        let json = r#"{
581            "id": "idx-001",
582            "symbol": "SPX",
583            "tradable_chain_ids": ["chain-aaa", "chain-bbb"]
584        }"#;
585        let idx: IndexInstrument = serde_json::from_str(json).unwrap();
586        assert_eq!(idx.id.as_deref(), Some("idx-001"));
587        assert_eq!(idx.symbol.as_deref(), Some("SPX"));
588        let chains = idx.tradable_chain_ids.unwrap();
589        assert_eq!(chains.len(), 2);
590        assert_eq!(chains[0], "chain-aaa");
591    }
592
593    #[test]
594    fn index_instrument_deserializes_no_chains() {
595        let json = r#"{"id": "idx-002", "symbol": "VIX"}"#;
596        let idx: IndexInstrument = serde_json::from_str(json).unwrap();
597        assert_eq!(idx.symbol.as_deref(), Some("VIX"));
598        assert!(idx.tradable_chain_ids.is_none());
599    }
600
601    #[test]
602    fn index_quote_deserializes_doubly_nested_wire_response() {
603        let wire = r#"{"status":"SUCCESS","data":{"status":"SUCCESS","data":{
604            "value":"7126.06",
605            "venue_timestamp":"2026-04-17T16:38:34.8016-04:00",
606            "symbol":"SPX",
607            "instrument_id":"432fbbb8-b82c-454a-852d-eb85382c7066",
608            "state":"",
609            "updated_at":"2026-04-17T17:57:11.709844895-04:00"
610        }}}"#;
611        let wrapper: IndexQuoteWrapper = serde_json::from_str(wire).unwrap();
612        let quote = &wrapper.data.data;
613        assert_eq!(quote.value.as_deref(), Some("7126.06"));
614        assert_eq!(
615            quote.venue_timestamp.as_deref(),
616            Some("2026-04-17T16:38:34.8016-04:00")
617        );
618        assert_eq!(quote.symbol.as_deref(), Some("SPX"));
619        assert_eq!(
620            quote.instrument_id.as_deref(),
621            Some("432fbbb8-b82c-454a-852d-eb85382c7066")
622        );
623        // Robinhood returns an empty state string on the wire; we pass it through.
624        assert_eq!(quote.state.as_deref(), Some(""));
625        assert_eq!(
626            quote.updated_at.as_deref(),
627            Some("2026-04-17T17:57:11.709844895-04:00")
628        );
629    }
630
631    #[test]
632    fn index_symbols_list_contains_expected() {
633        assert!(INDEX_SYMBOLS.contains(&"SPX"));
634        assert!(INDEX_SYMBOLS.contains(&"NDX"));
635        assert!(INDEX_SYMBOLS.contains(&"VIX"));
636        assert!(INDEX_SYMBOLS.contains(&"RUT"));
637        assert!(INDEX_SYMBOLS.contains(&"XSP"));
638        assert!(!INDEX_SYMBOLS.contains(&"AAPL"));
639    }
640
641    #[test]
642    fn index_chain_symbol_maps_correctly() {
643        assert_eq!(index_chain_symbol("SPX"), "SPXW");
644        assert_eq!(index_chain_symbol("NDX"), "NDXP");
645        assert_eq!(index_chain_symbol("VIX"), "VIXW");
646        assert_eq!(index_chain_symbol("RUT"), "RUTW");
647        assert_eq!(index_chain_symbol("XSP"), "XSP");
648        assert_eq!(index_chain_symbol("AAPL"), "AAPL");
649    }
650}