Skip to main content

rhood_core/endpoints/
user.rs

1use crate::api::paths;
2use crate::client::RobinhoodClient;
3use crate::models::account::AccountProfile;
4use crate::models::user::{DayTradeCheck, UserProfile};
5use crate::pagination::ResultsResponse;
6use crate::{Result, RhoodError};
7
8/// The raw recent-day-trades endpoint payload.
9#[derive(serde::Deserialize)]
10struct RecentDayTradesResponse {
11    #[serde(default)]
12    equity_day_trades: Vec<serde_json::Value>,
13    #[serde(default)]
14    option_day_trades: Vec<serde_json::Value>,
15}
16
17/// Returns whether margin balances indicate a pattern-day-trader flag.
18pub(crate) fn is_flagged_pdt(margin: &crate::models::account::MarginBalances) -> bool {
19    margin.is_pdt_forever.unwrap_or(false) || margin.marked_pattern_day_trader_date.is_some()
20}
21
22impl RobinhoodClient {
23    /// Fetches the authenticated user's profile.
24    ///
25    /// # Errors
26    ///
27    /// Returns an error if the HTTP request fails or the response cannot be
28    /// deserialized.
29    pub async fn get_user_profile(&self) -> Result<UserProfile> {
30        self.get(&self.api_url(paths::USER)).await
31    }
32
33    /// Fetches recent day trades for the user's account.
34    ///
35    /// Discovers the account number from the account endpoint, then fetches
36    /// day trade data. The PDT flag is derived from the account's margin
37    /// balances.
38    ///
39    /// # Errors
40    ///
41    /// Returns [`RhoodError::NotAuthenticated`] if no account is found.
42    pub async fn get_day_trades(&self) -> Result<DayTradeCheck> {
43        let resp: ResultsResponse<AccountProfile> = self
44            .get_with_params(
45                &self.api_url(paths::ACCOUNTS),
46                &[("default_to_all_accounts", "true")],
47            )
48            .await?;
49        // Pull the account number and PDT flag before the next await so we
50        // don't hold a borrow of `resp` across it.
51        let (account_number, flagged) = {
52            let account = resp.results.first().ok_or(RhoodError::NotAuthenticated)?;
53            let number = account
54                .account_number
55                .clone()
56                .ok_or(RhoodError::NotAuthenticated)?;
57            let flagged = account
58                .margin_balances
59                .as_ref()
60                .map(is_flagged_pdt)
61                .unwrap_or(false);
62            (number, flagged)
63        };
64        let url = format!(
65            "{}{account_number}/recent_day_trades/",
66            self.api_url(paths::DAY_TRADES)
67        );
68        let recent: RecentDayTradesResponse = self.get(&url).await?;
69        #[expect(
70            clippy::arithmetic_side_effects,
71            clippy::cast_possible_wrap,
72            reason = "API response collections are bounded by allocatable vector length, which is below i64::MAX"
73        )]
74        let day_trade_count =
75            (recent.equity_day_trades.len() + recent.option_day_trades.len()) as i64;
76        Ok(DayTradeCheck {
77            equity_day_trades: recent.equity_day_trades,
78            option_day_trades: recent.option_day_trades,
79            day_trade_count,
80            flagged_as_pattern_day_trader: flagged,
81        })
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use crate::models::user::{DayTradeCheck, UserProfile};
88
89    #[test]
90    fn user_profile_deserializes() {
91        let json = r#"{
92            "id": "user-001",
93            "username": "alice@example.com",
94            "first_name": "Alice",
95            "last_name": "Smith",
96            "email": "alice@example.com",
97            "created_at": "2024-01-01T00:00:00Z"
98        }"#;
99        let user: UserProfile = serde_json::from_str(json).unwrap();
100        assert_eq!(user.username.as_deref(), Some("alice@example.com"));
101        assert_eq!(user.first_name.as_deref(), Some("Alice"));
102    }
103
104    #[test]
105    fn user_profile_handles_missing_fields() {
106        let json = r#"{"id": "user-002"}"#;
107        let user: UserProfile = serde_json::from_str(json).unwrap();
108        assert_eq!(user.id.as_deref(), Some("user-002"));
109        assert!(user.email.is_none());
110    }
111
112    #[test]
113    fn day_trade_check_new_shape_deserializes() {
114        let json = r#"{
115            "equity_day_trades": [],
116            "option_day_trades": [],
117            "day_trade_count": 0,
118            "flagged_as_pattern_day_trader": false
119        }"#;
120        let check: DayTradeCheck = serde_json::from_str(json).unwrap();
121        assert_eq!(check.day_trade_count, 0);
122        assert!(!check.flagged_as_pattern_day_trader);
123        assert!(check.equity_day_trades.is_empty());
124        assert!(check.option_day_trades.is_empty());
125    }
126
127    #[test]
128    fn recent_day_trades_parses_real_payload() {
129        let json =
130            r#"{"account_number":"767920911","equity_day_trades":[],"option_day_trades":[]}"#;
131        let parsed: super::RecentDayTradesResponse = serde_json::from_str(json).unwrap();
132        assert!(parsed.equity_day_trades.is_empty());
133        assert!(parsed.option_day_trades.is_empty());
134    }
135
136    #[test]
137    fn pdt_flag_false_when_not_marked() {
138        use crate::models::account::MarginBalances;
139        let m = MarginBalances {
140            is_pdt_forever: Some(false),
141            marked_pattern_day_trader_date: None,
142            ..Default::default()
143        };
144        assert!(!super::is_flagged_pdt(&m));
145    }
146
147    #[test]
148    fn pdt_flag_true_when_marked() {
149        use crate::models::account::MarginBalances;
150        let m = MarginBalances {
151            is_pdt_forever: Some(false),
152            marked_pattern_day_trader_date: Some("2026-01-01".into()),
153            ..Default::default()
154        };
155        assert!(super::is_flagged_pdt(&m));
156        let forever = MarginBalances {
157            is_pdt_forever: Some(true),
158            ..Default::default()
159        };
160        assert!(super::is_flagged_pdt(&forever));
161    }
162}