Skip to main content

rhood_core/endpoints/
account.rs

1use crate::api::paths;
2use crate::client::RobinhoodClient;
3use crate::models::account::*;
4use crate::pagination::ResultsResponse;
5use crate::{Result, RhoodError};
6
7impl RobinhoodClient {
8    /// Fetches the unified account summary.
9    ///
10    /// Returns a comprehensive snapshot including buying power, equity,
11    /// cash, and margin health from the bonfire API.
12    ///
13    /// # Errors
14    ///
15    /// Returns [`RhoodError::NotAuthenticated`] if the account number
16    /// cannot be determined. Also returns an error on HTTP or
17    /// deserialization failures.
18    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    /// Fetches the basic account profile information.
31    ///
32    /// Returns details such as account number, type, and status.
33    ///
34    /// # Errors
35    ///
36    /// Returns [`RhoodError::NotAuthenticated`] if no account is found.
37    /// Also returns an error on HTTP or deserialization failures.
38    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    /// Fetches the portfolio profile containing equity, market value, and
52    /// related financial summaries.
53    ///
54    /// # Errors
55    ///
56    /// Returns [`RhoodError::NotAuthenticated`] if no portfolio is found.
57    /// Also returns an error on HTTP or deserialization failures.
58    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    /// Fetches all stock positions with a non-zero quantity.
68    ///
69    /// Excludes positions that have been fully closed (quantity of zero).
70    ///
71    /// Instrument URLs are resolved to ticker symbols on a best-effort basis
72    /// via [`enrich_position_symbols`]; any failure, including a failure of
73    /// the batched symbol-resolution request, is silently ignored so that the
74    /// caller always receives the raw positions.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if the HTTP request fails or the response cannot be
79    /// deserialized.
80    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    /// Fetches all stock positions, including those with a zero quantity.
93    ///
94    /// Instrument URLs are resolved to ticker symbols on a best-effort basis
95    /// via [`enrich_position_symbols`]; any failure, including a failure of
96    /// the batched symbol-resolution request, is silently ignored so that the
97    /// caller always receives the raw positions.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if the HTTP request fails or the response cannot be
102    /// deserialized.
103    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    /// Backfills `symbol` on each position by resolving its instrument URL to a
116    /// ticker via a single batched `/instruments/?ids=` request. Best-effort:
117    /// positions whose URL can't be parsed or resolved are left with `symbol = None`.
118    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        // Test the pure mapping logic: given a position with an instrument URL
245        // and a resolved map, symbol should be set after the apply step.
246        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        // Simulate what enrich_position_symbols does after calling resolve_symbols
261        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        // Only apply if symbol is None (mirrors enrich logic)
298        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        // Should not be replaced because symbol was already set
310        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}