rhood_core/endpoints/
futures.rs1use 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 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 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 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), ¶ms)
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 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), ¶ms)
95 .await?;
96 Ok(wrapper.data.into_iter().map(|item| item.data).collect())
97 }
98
99 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 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 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, ¶ms).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}