Skip to main content

rhood_core/endpoints/
futures.rs

1use std::sync::Arc;
2
3use crate::api::paths;
4use crate::client::RobinhoodClient;
5use crate::models::futures::*;
6use crate::pagination::ResultsResponse;
7use crate::{Result, RhoodError};
8
9impl RobinhoodClient {
10    /// Fetches a futures contract by its symbol (e.g., "/ESH26" or "ESH26").
11    ///
12    /// # Errors
13    ///
14    /// Returns an error if the contract is not found or on HTTP failures.
15    pub async fn get_futures_contract(&self, symbol: &str) -> Result<FuturesContract> {
16        let url = format!(
17            "{}symbol/{}/",
18            self.api_url(paths::FUTURES_CONTRACTS),
19            symbol.to_uppercase()
20        );
21        let wrapper: FuturesContractWrapper = self.get_futures(&url).await?;
22        Ok(wrapper.result)
23    }
24
25    /// Cached wrapper around [`get_futures_contract`](Self::get_futures_contract).
26    ///
27    /// Returns an [`Arc<FuturesContract>`] shared with other callers for the
28    /// configured TTL. When caching is disabled (`CacheConfig::enabled = false`)
29    /// the upstream endpoint is hit on every call.
30    pub async fn cached_futures_contract(&self, symbol: &str) -> Result<Arc<FuturesContract>> {
31        let key = symbol.to_uppercase();
32        if !self.resolvers.enabled {
33            return Ok(Arc::new(self.get_futures_contract(&key).await?));
34        }
35        if let Some(hit) = self.resolvers.futures_contracts.get(&key).await {
36            return Ok(hit);
37        }
38        let contract = self.get_futures_contract(&key).await?;
39        let wrapped = Arc::new(contract);
40        self.resolvers
41            .futures_contracts
42            .insert(key, wrapped.clone())
43            .await;
44        Ok(wrapped)
45    }
46
47    /// Fetches a real-time futures quote by resolving a symbol to its instrument ID.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`RhoodError::InvalidSymbol`] if the contract cannot be found
52    /// or has no instrument ID. Also returns an error on HTTP failures.
53    pub async fn get_futures_quote(&self, symbol: &str) -> Result<FuturesQuote> {
54        let contract = self.cached_futures_contract(symbol).await?;
55        let instrument_id = contract
56            .id
57            .clone()
58            .ok_or_else(|| RhoodError::InvalidSymbol(symbol.to_string()))?;
59        let params = [("ids", instrument_id.as_str())];
60        let wrapper: FuturesQuoteDataWrapper = self
61            .get_futures_with_params(&self.api_url(paths::FUTURES_QUOTES), &params)
62            .await?;
63        wrapper
64            .data
65            .into_iter()
66            .next()
67            .map(|item| item.data)
68            .ok_or_else(|| RhoodError::InvalidSymbol(symbol.to_string()))
69    }
70
71    /// Fetches real-time futures quotes for multiple symbols in a single request.
72    ///
73    /// Resolves each symbol to its instrument ID concurrently via the resolver
74    /// cache, then batches the quote request. Cache hits return immediately;
75    /// misses fan out via [`futures::future::try_join_all`] so N upstream
76    /// contract lookups run in parallel rather than sequentially.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if any symbol cannot be resolved or on HTTP failures.
81    pub async fn get_futures_quotes(&self, symbols: &[&str]) -> Result<Vec<FuturesQuote>> {
82        let resolved_ids: Vec<String> =
83            ::futures::future::try_join_all(symbols.iter().map(|symbol| async move {
84                let contract = self.cached_futures_contract(symbol).await?;
85                contract
86                    .id
87                    .clone()
88                    .ok_or_else(|| RhoodError::InvalidSymbol((*symbol).to_string()))
89            }))
90            .await?;
91        let joined_ids = resolved_ids.join(",");
92        let params = [("ids", joined_ids.as_str())];
93        let wrapper: FuturesQuoteDataWrapper = self
94            .get_futures_with_params(&self.api_url(paths::FUTURES_QUOTES), &params)
95            .await?;
96        Ok(wrapper.data.into_iter().map(|item| item.data).collect())
97    }
98
99    /// Discovers the Robinhood futures account ID.
100    ///
101    /// Queries the Ceres accounts endpoint and filters for
102    /// `accountType == "FUTURES"`. Returns `None` if the user has no
103    /// futures account.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error on HTTP or deserialization failures.
108    pub async fn get_futures_account_id(&self) -> Result<Option<String>> {
109        let resp: ResultsResponse<FuturesAccount> = self
110            .get_futures(&self.api_url(paths::FUTURES_ACCOUNTS))
111            .await?;
112        Ok(resp
113            .results
114            .into_iter()
115            .find(|account| account.account_type.as_deref() == Some("FUTURES"))
116            .and_then(|account| account.id))
117    }
118
119    /// Cached wrapper around [`get_futures_account_id`](Self::get_futures_account_id).
120    ///
121    /// The futures account id never changes for a given session, so it lives
122    /// in a [`tokio::sync::OnceCell`]. When caching is disabled, every call
123    /// hits upstream.
124    ///
125    /// Unlike [`get_futures_account_id`](Self::get_futures_account_id), this
126    /// wrapper returns [`RhoodError::InvalidParameter`] rather than
127    /// `Ok(None)` when the user has no futures account, matching the error
128    /// shape existing call sites already expect.
129    pub async fn cached_futures_account_id(&self) -> Result<String> {
130        if !self.resolvers.enabled {
131            return self
132                .get_futures_account_id()
133                .await?
134                .ok_or_else(|| RhoodError::InvalidParameter("No futures account found".into()));
135        }
136        let cached = self
137            .resolvers
138            .futures_account_id
139            .get_or_try_init(|| async {
140                self.get_futures_account_id()
141                    .await?
142                    .ok_or_else(|| RhoodError::InvalidParameter("No futures account found".into()))
143            })
144            .await?;
145        Ok(cached.clone())
146    }
147
148    /// Fetches all futures orders with optional date filtering.
149    ///
150    /// Discovers the futures account ID automatically. Uses cursor-based
151    /// pagination to fetch all pages.
152    ///
153    /// # Errors
154    ///
155    /// Returns [`RhoodError::InvalidParameter`] if no futures account exists.
156    /// Also returns an error on HTTP or deserialization failures.
157    pub async fn get_all_futures_orders(&self, since: Option<&str>) -> Result<Vec<FuturesOrder>> {
158        let account_id = self.cached_futures_account_id().await?;
159        let url = format!(
160            "{}{account_id}/orders",
161            self.api_url(paths::FUTURES_ACCOUNTS)
162        );
163        let mut params: Vec<(&str, &str)> = vec![("contractType", "OUTRIGHT")];
164        if let Some(date) = since {
165            params.push(("updated_at[gte]", date));
166        }
167        self.get_futures_cursor_paginated(&url, &params).await
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use crate::models::futures::{
174        FuturesContract, FuturesContractWrapper, FuturesOrder, FuturesQuoteDataWrapper,
175    };
176
177    #[test]
178    fn futures_contract_deserializes() {
179        let json = r#"{
180            "id": "c60db22e-536d-43b4-9083-17717de8d217",
181            "symbol": "/ESH26:XCME",
182            "displaySymbol": "/ESH26",
183            "description": "E-mini S&P 500 Mar 2026",
184            "multiplier": "50",
185            "expiration": "2026-03-21",
186            "tradability": "tradable",
187            "state": "active"
188        }"#;
189        let contract: FuturesContract = serde_json::from_str(json).unwrap();
190        assert_eq!(
191            contract.id.as_deref(),
192            Some("c60db22e-536d-43b4-9083-17717de8d217")
193        );
194        assert_eq!(contract.symbol.as_deref(), Some("/ESH26:XCME"));
195        assert_eq!(contract.display_symbol.as_deref(), Some("/ESH26"));
196        assert_eq!(contract.multiplier.as_deref(), Some("50"));
197    }
198
199    #[test]
200    fn futures_quote_accepts_integer_sizes_from_live_wire() {
201        let wire = r#"{
202          "status":"SUCCESS",
203          "data":[{"status":"SUCCESS","data":{
204            "ask_price":"7164.25","ask_size":1,
205            "ask_venue_timestamp":"2026-04-17T17:00:00.12674-04:00",
206            "bid_price":"7163.75","bid_size":5,
207            "bid_venue_timestamp":"2026-04-17T17:00:00.12674-04:00",
208            "last_trade_price":"7164.25","last_trade_size":1,
209            "last_trade_venue_timestamp":"2026-04-17T16:59:58.64959-04:00",
210            "symbol":"/ESM26:XCME",
211            "instrument_id":"e7b95e72-9aa2-4779-89e1-c404163799ed",
212            "state":"active",
213            "updated_at":"2026-04-17T17:00:00.12674-04:00",
214            "out_of_band":false
215          }}]
216        }"#;
217        let wrapper: FuturesQuoteDataWrapper = serde_json::from_str(wire).unwrap();
218        let quote = &wrapper.data[0].data;
219        assert_eq!(quote.ask_size, Some(1));
220        assert_eq!(quote.bid_size, Some(5));
221        assert_eq!(quote.last_trade_size, Some(1));
222        assert_eq!(quote.ask_price.as_deref(), Some("7164.25"));
223        assert_eq!(quote.bid_price.as_deref(), Some("7163.75"));
224        assert_eq!(
225            quote.instrument_id.as_deref(),
226            Some("e7b95e72-9aa2-4779-89e1-c404163799ed")
227        );
228        assert_eq!(quote.state.as_deref(), Some("active"));
229    }
230
231    #[test]
232    fn futures_order_deserializes() {
233        let json = r#"{
234            "orderId": "order-123",
235            "orderState": "FILLED",
236            "quantity": "1",
237            "filledQuantity": "1",
238            "averagePrice": "6903.50",
239            "orderLegs": [{"side": "BUY"}],
240            "realizedPnl": {"realizedPnl": {"amount": "-50.00", "currency": "USD"}},
241            "totalFee": {"amount": "3.10", "currency": "USD"},
242            "createdAt": "2026-01-15T10:00:00Z",
243            "updatedAt": "2026-01-15T10:01:00Z"
244        }"#;
245        let order: FuturesOrder = serde_json::from_str(json).unwrap();
246        assert_eq!(order.order_id.as_deref(), Some("order-123"));
247        assert_eq!(order.order_state.as_deref(), Some("FILLED"));
248        assert_eq!(order.filled_quantity.as_deref(), Some("1"));
249        assert!(order.realized_pnl.is_some());
250        assert!(order.total_fee.is_some());
251    }
252
253    #[test]
254    fn futures_contract_wrapper_deserializes() {
255        let json = r#"{"result": {"id": "abc", "symbol": "/ESH26:XCME"}}"#;
256        let wrapper: FuturesContractWrapper = serde_json::from_str(json).unwrap();
257        assert_eq!(wrapper.result.id.as_deref(), Some("abc"));
258    }
259
260    #[test]
261    fn futures_quote_data_wrapper_deserializes() {
262        let json = r#"{"data": [{"data": {"bid_price": "100", "ask_price": "101"}}]}"#;
263        let wrapper: FuturesQuoteDataWrapper = serde_json::from_str(json).unwrap();
264        assert_eq!(wrapper.data.len(), 1);
265        assert_eq!(wrapper.data[0].data.bid_price.as_deref(), Some("100"));
266    }
267}