1use crate::api::paths;
2use crate::client::RobinhoodClient;
3use crate::models::account::*;
4use crate::pagination::ResultsResponse;
5use crate::{Result, RhoodError};
6
7impl RobinhoodClient {
8 pub async fn get_account_summary(&self) -> Result<AccountSummary> {
19 let profile = self.get_account_profile().await?;
20 let account_number = profile.account_number.ok_or(RhoodError::NotAuthenticated)?;
21 let url = format!(
22 "{}/accounts/{}{}",
23 self.config().api.bonfire_url,
24 account_number,
25 paths::ACCOUNT_SUMMARY_SUFFIX
26 );
27 self.get(&url).await
28 }
29
30 pub async fn get_account_profile(&self) -> Result<AccountProfile> {
39 let resp: ResultsResponse<AccountProfile> = self
40 .get_with_params(
41 &self.api_url(paths::ACCOUNTS),
42 &[("default_to_all_accounts", "true")],
43 )
44 .await?;
45 resp.results
46 .into_iter()
47 .next()
48 .ok_or(RhoodError::NotAuthenticated)
49 }
50
51 pub async fn get_portfolio(&self) -> Result<PortfolioProfile> {
59 let resp: ResultsResponse<PortfolioProfile> =
60 self.get(&self.api_url(paths::PORTFOLIOS)).await?;
61 resp.results
62 .into_iter()
63 .next()
64 .ok_or(RhoodError::NotAuthenticated)
65 }
66
67 pub async fn get_positions(&self) -> Result<Vec<Position>> {
81 let mut positions: Vec<Position> = self
82 .get_paginated(&self.api_url(paths::POSITIONS), &[("nonzero", "true")])
83 .await?;
84 #[expect(
85 clippy::let_underscore_must_use,
86 reason = "the function's documented best-effort enrichment contract returns raw positions when symbol resolution fails"
87 )]
88 let _ = self.enrich_position_symbols(&mut positions).await;
89 Ok(positions)
90 }
91
92 pub async fn get_all_positions(&self) -> Result<Vec<Position>> {
104 let mut positions: Vec<Position> = self
105 .get_paginated(&self.api_url(paths::POSITIONS), &[])
106 .await?;
107 #[expect(
108 clippy::let_underscore_must_use,
109 reason = "the function's documented best-effort enrichment contract returns raw positions when symbol resolution fails"
110 )]
111 let _ = self.enrich_position_symbols(&mut positions).await;
112 Ok(positions)
113 }
114
115 pub async fn enrich_position_symbols(&self, positions: &mut [Position]) -> Result<()> {
119 let uuids: Vec<String> = positions
120 .iter()
121 .filter(|p| p.symbol.is_none())
122 .filter_map(|p| p.instrument.as_deref())
123 .filter_map(|url| crate::util::instrument_id_from_url(url))
124 .map(|id| id.to_string())
125 .collect();
126 if uuids.is_empty() {
127 return Ok(());
128 }
129 let map = self.resolve_symbols(&uuids).await?;
130 for p in positions.iter_mut() {
131 if p.symbol.is_none()
132 && let Some(url) = p.instrument.as_deref()
133 && let Some(id) = crate::util::instrument_id_from_url(url)
134 && let Some(sym) = map.get(id)
135 {
136 p.symbol = Some(sym.clone());
137 }
138 }
139 Ok(())
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use crate::models::account::{AccountProfile, AccountSummary, PortfolioProfile, Position};
146
147 #[test]
148 fn account_profile_deserializes() {
149 let json = r#"{
150 "account_number": "ABC123",
151 "buying_power": "5000.00",
152 "cash": "1000.00",
153 "type": "margin",
154 "created_at": "2025-01-01T00:00:00Z"
155 }"#;
156 let profile: AccountProfile = serde_json::from_str(json).unwrap();
157 assert_eq!(profile.account_number.as_deref(), Some("ABC123"));
158 assert_eq!(profile.buying_power.as_deref(), Some("5000.00"));
159 assert_eq!(profile.account_type.as_deref(), Some("margin"));
160 }
161
162 #[test]
163 fn account_summary_deserializes_real_api_shape() {
164 let json = r#"{
165 "account_buying_power": {"amount": "4987.64", "currency_code": "USD", "currency_id": "1072fc76"},
166 "total_equity": {"amount": "2372.89", "currency_code": "USD", "currency_id": "1072fc76"},
167 "total_market_value": {"amount": "5", "currency_code": "USD", "currency_id": "1072fc76"},
168 "uninvested_cash": {"amount": "2367.89", "currency_code": "USD", "currency_id": "1072fc76"},
169 "withdrawable_cash": {"amount": "2367.89", "currency_code": "USD", "currency_id": "1072fc76"},
170 "portfolio_equity": {"amount": "2372.89", "currency_code": "USD", "currency_id": "1072fc76"},
171 "near_margin_call": false,
172 "account_number": "767920911",
173 "brokerage_account_type": "individual",
174 "has_futures_account": true,
175 "margin_health": {
176 "margin_health_state": "healthy",
177 "margin_buffer": "1.0000",
178 "margin_buffer_amount": {"currency_code": "USD", "currency_id": "1072fc76", "amount": "2372.89"}
179 }
180 }"#;
181 let summary: AccountSummary = serde_json::from_str(json).unwrap();
182 let buying_power = summary.account_buying_power.unwrap();
183 assert_eq!(buying_power.amount.as_deref(), Some("4987.64"));
184 assert_eq!(buying_power.currency_code.as_deref(), Some("USD"));
185 assert_eq!(summary.account_number.as_deref(), Some("767920911"));
186 assert_eq!(summary.near_margin_call, Some(false));
187 assert_eq!(summary.has_futures_account, Some(true));
188 let margin = summary.margin_health.unwrap();
189 assert_eq!(margin.margin_health_state.as_deref(), Some("healthy"));
190 assert_eq!(margin.margin_buffer.as_deref(), Some("1.0000"));
191 assert!(summary.crypto.is_none());
192 }
193
194 #[test]
195 fn position_deserializes() {
196 let json = r#"{
197 "instrument": "https://api.robinhood.com/instruments/abc/",
198 "average_buy_price": "150.00",
199 "quantity": "10.0000",
200 "shares_held_for_sells": "0.0000"
201 }"#;
202 let pos: Position = serde_json::from_str(json).unwrap();
203 assert_eq!(pos.quantity.as_deref(), Some("10.0000"));
204 assert_eq!(pos.average_buy_price.as_deref(), Some("150.00"));
205 assert_eq!(pos.symbol, None);
206 }
207
208 #[test]
209 fn position_deserializes_symbol_field() {
210 let json = r#"{
211 "instrument": "https://api.robinhood.com/instruments/450dfc6d-5510-4d40-abfb-f633b7d9be3e/",
212 "average_buy_price": "175.50",
213 "quantity": "5.0000",
214 "symbol": "AAPL"
215 }"#;
216 let pos: Position = serde_json::from_str(json).unwrap();
217 assert_eq!(pos.symbol.as_deref(), Some("AAPL"));
218 assert_eq!(pos.quantity.as_deref(), Some("5.0000"));
219 }
220
221 #[test]
222 fn position_symbol_serializes_round_trip() {
223 let pos = Position {
224 account: None,
225 instrument: Some(
226 "https://api.robinhood.com/instruments/450dfc6d-5510-4d40-abfb-f633b7d9be3e/"
227 .to_string(),
228 ),
229 symbol: Some("TSLA".to_string()),
230 average_buy_price: Some("250.00".to_string()),
231 quantity: Some("3.0000".to_string()),
232 shares_held_for_buys: None,
233 shares_held_for_sells: None,
234 created_at: None,
235 updated_at: None,
236 };
237 let json = serde_json::to_string(&pos).unwrap();
238 let deserialized: Position = serde_json::from_str(&json).unwrap();
239 assert_eq!(deserialized.symbol.as_deref(), Some("TSLA"));
240 }
241
242 #[test]
243 fn enrich_position_symbols_applies_map() {
244 let uuid = "450dfc6d-5510-4d40-abfb-f633b7d9be3e";
247 let url = format!("https://api.robinhood.com/instruments/{uuid}/");
248 let mut pos = Position {
249 account: None,
250 instrument: Some(url.clone()),
251 symbol: None,
252 average_buy_price: Some("100.00".to_string()),
253 quantity: Some("1.0000".to_string()),
254 shares_held_for_buys: None,
255 shares_held_for_sells: None,
256 created_at: None,
257 updated_at: None,
258 };
259
260 let mut map = std::collections::HashMap::new();
262 map.insert(uuid.to_string(), "AAPL".to_string());
263
264 let positions: &mut [Position] = std::slice::from_mut(&mut pos);
265 for p in positions.iter_mut() {
266 if p.symbol.is_none()
267 && let Some(instrument_url) = p.instrument.as_deref()
268 && let Some(id) = crate::util::instrument_id_from_url(instrument_url)
269 && let Some(sym) = map.get(id)
270 {
271 p.symbol = Some(sym.clone());
272 }
273 }
274
275 assert_eq!(pos.symbol.as_deref(), Some("AAPL"));
276 }
277
278 #[test]
279 fn enrich_position_symbols_skips_already_set() {
280 let uuid = "450dfc6d-5510-4d40-abfb-f633b7d9be3e";
281 let url = format!("https://api.robinhood.com/instruments/{uuid}/");
282 let mut pos = Position {
283 account: None,
284 instrument: Some(url),
285 symbol: Some("EXISTING".to_string()),
286 average_buy_price: None,
287 quantity: None,
288 shares_held_for_buys: None,
289 shares_held_for_sells: None,
290 created_at: None,
291 updated_at: None,
292 };
293
294 let mut map = std::collections::HashMap::new();
295 map.insert(uuid.to_string(), "REPLACED".to_string());
296
297 let positions: &mut [Position] = std::slice::from_mut(&mut pos);
299 for p in positions.iter_mut() {
300 if p.symbol.is_none()
301 && let Some(instrument_url) = p.instrument.as_deref()
302 && let Some(id) = crate::util::instrument_id_from_url(instrument_url)
303 && let Some(sym) = map.get(id)
304 {
305 p.symbol = Some(sym.clone());
306 }
307 }
308
309 assert_eq!(pos.symbol.as_deref(), Some("EXISTING"));
311 }
312
313 #[test]
314 fn portfolio_profile_deserializes_real_api_shape() {
315 let json = r#"{
316 "url": "https://api.robinhood.com/portfolios/767920911/",
317 "account": "https://api.robinhood.com/accounts/767920911/",
318 "start_date": "2023-06-08",
319 "market_value": "0.0000",
320 "equity": "1036.2900",
321 "extended_hours_market_value": "0.0000",
322 "extended_hours_equity": "1036.2900",
323 "extended_hours_portfolio_equity": "1036.2900",
324 "last_core_market_value": "0.0000",
325 "last_core_equity": "1036.2900",
326 "last_core_portfolio_equity": "1036.2900",
327 "excess_margin": "1036.2900",
328 "excess_maintenance": "1036.2900",
329 "excess_margin_with_uncleared_deposits": "1036.2900",
330 "excess_maintenance_with_uncleared_deposits": "1036.2900",
331 "equity_previous_close": "1036.2900",
332 "portfolio_equity_previous_close": "1036.2900",
333 "adjusted_equity_previous_close": "1036.2900",
334 "adjusted_portfolio_equity_previous_close": "1036.2900",
335 "withdrawable_amount": "1036.29",
336 "unwithdrawable_deposits": "0.0000",
337 "unwithdrawable_grants": "0.0000",
338 "is_primary_account": true,
339 "non_usd_currency_equity": "0.0000"
340 }"#;
341 let portfolio: PortfolioProfile = serde_json::from_str(json).unwrap();
342 assert_eq!(portfolio.start_date.as_deref(), Some("2023-06-08"));
343 assert_eq!(portfolio.equity.as_deref(), Some("1036.2900"));
344 assert_eq!(portfolio.excess_maintenance.as_deref(), Some("1036.2900"));
345 assert_eq!(
346 portfolio.equity_previous_close.as_deref(),
347 Some("1036.2900")
348 );
349 assert_eq!(portfolio.is_primary_account, Some(true));
350 assert_eq!(portfolio.non_usd_currency_equity.as_deref(), Some("0.0000"));
351 assert_eq!(
352 portfolio.last_core_portfolio_equity.as_deref(),
353 Some("1036.2900")
354 );
355 }
356
357 #[test]
358 fn account_profile_deserializes_with_margin_balances() {
359 let json = r#"{
360 "account_number": "767920911",
361 "type": "margin",
362 "state": "active",
363 "buying_power": "2493.8200",
364 "cash": "2367.8900",
365 "drip_enabled": true,
366 "has_futures_account": true,
367 "margin_balances": {
368 "cash": "2367.8900",
369 "day_trade_buying_power": "2493.8200",
370 "overnight_buying_power": "2493.8200",
371 "leverage_enabled": true,
372 "day_trades_protection": true,
373 "is_primary_account": true,
374 "is_pdt_forever": false
375 },
376 "instant_eligibility": {
377 "state": "ok",
378 "reason": "",
379 "additional_deposit_needed": "0.0000",
380 "created_at": "2023-06-08T18:08:00.938554Z"
381 }
382 }"#;
383 let account: AccountProfile = serde_json::from_str(json).unwrap();
384 assert_eq!(account.account_number.as_deref(), Some("767920911"));
385 assert_eq!(account.account_type.as_deref(), Some("margin"));
386 assert_eq!(account.state.as_deref(), Some("active"));
387 assert_eq!(account.drip_enabled, Some(true));
388 assert_eq!(account.has_futures_account, Some(true));
389 let margin = account.margin_balances.unwrap();
390 assert_eq!(margin.cash.as_deref(), Some("2367.8900"));
391 assert_eq!(margin.day_trade_buying_power.as_deref(), Some("2493.8200"));
392 assert_eq!(margin.leverage_enabled, Some(true));
393 assert_eq!(margin.is_pdt_forever, Some(false));
394 let eligibility = account.instant_eligibility.unwrap();
395 assert_eq!(eligibility.state.as_deref(), Some("ok"));
396 }
397}