1use crate::api::paths;
2use crate::client::RobinhoodClient;
3use crate::models::option::*;
4use crate::pagination::ResultsResponse;
5use crate::{Result, RhoodError};
6
7pub const INDEX_SYMBOLS: &[&str] = &["SPX", "NDX", "VIX", "RUT", "XSP"];
9
10pub 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 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 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), ¶ms)
74 .await
75 }
76
77 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 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 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 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), ¶ms)
190 .await?;
191 Ok(resp.results)
192 }
193
194 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 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), ¶ms)
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 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}