rhood_core/endpoints/
user.rs1use 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#[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
17pub(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 pub async fn get_user_profile(&self) -> Result<UserProfile> {
30 self.get(&self.api_url(paths::USER)).await
31 }
32
33 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 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}