Skip to main content

rhood_core/endpoints/
watchlists.rs

1use crate::api::paths;
2use crate::client::RobinhoodClient;
3use crate::models::watchlist::{Watchlist, WatchlistItem};
4use crate::{Result, RhoodError};
5
6impl RobinhoodClient {
7    /// Fetches all user watchlists.
8    ///
9    /// # Errors
10    ///
11    /// Returns an error if the HTTP request fails or the response cannot be
12    /// deserialized.
13    pub async fn get_watchlists(&self) -> Result<Vec<Watchlist>> {
14        self.get_paginated(
15            &self.api_url(paths::WATCHLISTS),
16            &[("owner_type", "custom")],
17        )
18        .await
19    }
20
21    /// Fetches a single watchlist by display name or ID.
22    ///
23    /// Tries display name first (case-insensitive), then falls back to exact
24    /// ID match. This allows users to look up watchlists with emoji names by ID.
25    ///
26    /// # Errors
27    ///
28    /// Returns [`RhoodError::InvalidParameter`] if no watchlist matches.
29    pub async fn get_watchlist(&self, name_or_id: &str) -> Result<Watchlist> {
30        let lists = self.get_watchlists().await?;
31        lists
32            .iter()
33            .find(|list| {
34                list.display_name
35                    .as_deref()
36                    .is_some_and(|name| name.eq_ignore_ascii_case(name_or_id))
37            })
38            .or_else(|| {
39                lists
40                    .iter()
41                    .find(|list| list.id.as_deref() == Some(name_or_id))
42            })
43            .cloned()
44            .ok_or_else(|| {
45                RhoodError::InvalidParameter(format!("Watchlist not found: {name_or_id}"))
46            })
47    }
48
49    /// Fetches the items in a watchlist by name or ID.
50    ///
51    /// Uses the `/discovery/lists/items/` endpoint which returns enriched
52    /// items with live market data (price, change, volume, etc.).
53    ///
54    /// # Errors
55    ///
56    /// Returns [`RhoodError::InvalidParameter`] if the watchlist contains
57    /// only option strategies (the discovery API does not support them).
58    /// Returns an error if the watchlist is not found or the items cannot be fetched.
59    pub async fn get_watchlist_items(&self, name_or_id: &str) -> Result<Vec<WatchlistItem>> {
60        let list = self.get_watchlist(name_or_id).await?;
61
62        let is_options_only = list.allowed_object_types.as_ref().is_some_and(|types| {
63            types
64                .iter()
65                .all(|object_type| object_type == "option_strategy")
66        });
67        if is_options_only {
68            let name = list.display_name.as_deref().unwrap_or("this watchlist");
69            return Err(RhoodError::InvalidParameter(format!(
70                "'{name}' is an options watchlist and cannot be listed via the discovery API"
71            )));
72        }
73
74        let list_id = list
75            .id
76            .as_deref()
77            .ok_or_else(|| RhoodError::InvalidParameter("Watchlist missing ID".into()))?;
78        self.get_paginated(
79            &self.api_url(paths::WATCHLIST_ITEMS),
80            &[("list_id", list_id)],
81        )
82        .await
83    }
84
85    /// Adds symbols to a watchlist.
86    ///
87    /// Resolves each symbol to its instrument ID, then adds them all in a
88    /// single bulk write. Requires writable mode.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`RhoodError::ReadOnlyMode`] if the client is in read-only mode.
93    /// Returns [`RhoodError::InvalidSymbol`] if any symbol cannot be resolved.
94    pub async fn add_to_watchlist(&self, name: &str, symbols: &[&str]) -> Result<()> {
95        self.require_writable()?;
96        let list = self.get_watchlist(name).await?;
97        let list_id = list
98            .id
99            .clone()
100            .ok_or_else(|| RhoodError::InvalidParameter("Watchlist missing ID".into()))?;
101
102        let mut object_ids = Vec::with_capacity(symbols.len());
103        for symbol in symbols {
104            let instrument = self
105                .cached_instrument(symbol)
106                .await?
107                .ok_or_else(|| RhoodError::InvalidSymbol((*symbol).to_string()))?;
108            let instrument_id = instrument
109                .id
110                .clone()
111                .ok_or_else(|| RhoodError::InvalidSymbol((*symbol).to_string()))?;
112            object_ids.push(instrument_id);
113        }
114        if object_ids.is_empty() {
115            return Ok(());
116        }
117        self.bulk_watchlist_edit(&list_id, &object_ids, "create")
118            .await?;
119        Ok(())
120    }
121
122    /// Removes symbols from a watchlist.
123    ///
124    /// Fetches the enriched watchlist items, matches the requested symbols
125    /// (case-insensitive), then removes the matches in a single bulk write.
126    /// Requires writable mode.
127    ///
128    /// Returns the number of symbols that were actually found and removed.
129    /// Symbols not present in the watchlist are silently skipped and do not
130    /// count toward the return value.
131    ///
132    /// # Errors
133    ///
134    /// Returns [`RhoodError::ReadOnlyMode`] if the client is in read-only mode.
135    /// Returns [`RhoodError::InvalidParameter`] if the watchlist is not found or
136    /// is missing an ID.
137    pub async fn remove_from_watchlist(&self, name: &str, symbols: &[&str]) -> Result<usize> {
138        self.require_writable()?;
139        let list = self.get_watchlist(name).await?;
140        let list_id = list
141            .id
142            .clone()
143            .ok_or_else(|| RhoodError::InvalidParameter("Watchlist missing ID".into()))?;
144        let items = self.get_watchlist_items(name).await?;
145
146        let object_ids: Vec<String> = symbols
147            .iter()
148            .filter_map(|symbol| {
149                items
150                    .iter()
151                    .find(|item| {
152                        item.symbol
153                            .as_deref()
154                            .is_some_and(|item_symbol| item_symbol.eq_ignore_ascii_case(symbol))
155                    })
156                    .and_then(|item| item.object_id.clone())
157            })
158            .collect();
159
160        if object_ids.is_empty() {
161            return Ok(0);
162        }
163        self.bulk_watchlist_edit(&list_id, &object_ids, "delete")
164            .await?;
165        Ok(object_ids.len())
166    }
167
168    /// Posts a bulk create/delete edit to the midlands lists write endpoint.
169    ///
170    /// The endpoint expects a body keyed by the list ID whose value is an array
171    /// of `{object_type, object_id, operation}` ops. The per-list nested
172    /// collection (`/midlands/lists/{id}/items/`) is not a usable write target:
173    /// POST 404s and DELETE on the nested item path also 404s with an HTML body.
174    async fn bulk_watchlist_edit(
175        &self,
176        list_id: &str,
177        object_ids: &[String],
178        operation: &str,
179    ) -> Result<()> {
180        let payload = bulk_watchlist_payload(list_id, object_ids, operation);
181        let _: serde_json::Value = self
182            .post_json(&self.api_url(paths::WATCHLIST_ITEMS_WRITE), &payload)
183            .await?;
184        Ok(())
185    }
186}
187
188/// Builds the midlands bulk-edit request body: an object keyed by the list ID
189/// whose value is an array of `{object_type, object_id, operation}` ops.
190///
191/// This exact keyed shape is required because a top-level array or an
192/// `{list_id, items}` object are both rejected by the API.
193fn bulk_watchlist_payload(
194    list_id: &str,
195    object_ids: &[String],
196    operation: &str,
197) -> serde_json::Value {
198    let ops: Vec<serde_json::Value> = object_ids
199        .iter()
200        .map(|object_id| {
201            serde_json::json!({
202                "object_type": "instrument",
203                "object_id": object_id,
204                "operation": operation,
205            })
206        })
207        .collect();
208    serde_json::json!({ list_id: ops })
209}
210
211#[cfg(test)]
212mod endpoint_tests {
213    use crate::client::RobinhoodClient;
214    use crate::config::RhoodConfig;
215    use secrecy::SecretString;
216    use wiremock::matchers::{method, path, query_param};
217    use wiremock::{Mock, MockServer, ResponseTemplate};
218
219    async fn client_for_server(base_url: &str) -> (tempfile::TempDir, RobinhoodClient) {
220        let dir = tempfile::tempdir().unwrap();
221        let mut config = RhoodConfig::default();
222        config.auth.token_cache_path = dir
223            .path()
224            .join("nonexistent-token.json")
225            .to_str()
226            .unwrap()
227            .to_string();
228        config.api.base_url = base_url.to_string();
229        let client = RobinhoodClient::with_config(config).unwrap();
230        client
231            .inject_test_auth(
232                SecretString::from("access-token"),
233                "Bearer".to_string(),
234                SecretString::from("refresh-token"),
235            )
236            .await;
237        (dir, client)
238    }
239
240    #[tokio::test]
241    async fn get_watchlists_follows_next_page_in_order() {
242        let server = MockServer::start().await;
243        let next_url = format!("{}/midlands/lists/?cursor=page-2", server.uri());
244        Mock::given(method("GET"))
245            .and(path("/midlands/lists/"))
246            .and(query_param("owner_type", "custom"))
247            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
248                "results": [{"id": "list-1", "display_name": "First"}],
249                "next": next_url,
250                "previous": null
251            })))
252            .mount(&server)
253            .await;
254        Mock::given(method("GET"))
255            .and(path("/midlands/lists/"))
256            .and(query_param("cursor", "page-2"))
257            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
258                "results": [{"id": "list-2", "display_name": "Second"}],
259                "next": null,
260                "previous": null
261            })))
262            .mount(&server)
263            .await;
264        let (_dir, client) = client_for_server(&server.uri()).await;
265
266        let lists = client.get_watchlists().await.unwrap();
267
268        let ids: Vec<_> = lists.iter().filter_map(|list| list.id.as_deref()).collect();
269        assert_eq!(ids, ["list-1", "list-2"]);
270    }
271
272    #[tokio::test]
273    async fn get_watchlist_finds_match_on_next_page() {
274        let server = MockServer::start().await;
275        let next_url = format!("{}/midlands/lists/?cursor=page-2", server.uri());
276        Mock::given(method("GET"))
277            .and(path("/midlands/lists/"))
278            .and(query_param("owner_type", "custom"))
279            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
280                "results": [{"id": "list-1", "display_name": "First"}],
281                "next": next_url,
282                "previous": null
283            })))
284            .mount(&server)
285            .await;
286        Mock::given(method("GET"))
287            .and(path("/midlands/lists/"))
288            .and(query_param("cursor", "page-2"))
289            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
290                "results": [{"id": "list-2", "display_name": "Later"}],
291                "next": null,
292                "previous": null
293            })))
294            .mount(&server)
295            .await;
296        let (_dir, client) = client_for_server(&server.uri()).await;
297
298        let list = client.get_watchlist("Later").await.unwrap();
299
300        assert_eq!(list.id.as_deref(), Some("list-2"));
301    }
302
303    #[tokio::test]
304    async fn get_watchlist_items_follows_next_page_in_order() {
305        let server = MockServer::start().await;
306        Mock::given(method("GET"))
307            .and(path("/midlands/lists/"))
308            .and(query_param("owner_type", "custom"))
309            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
310                "results": [{"id": "list-1", "display_name": "Tech"}],
311                "next": null,
312                "previous": null
313            })))
314            .mount(&server)
315            .await;
316        let next_url = format!("{}/discovery/lists/items/?cursor=page-2", server.uri());
317        Mock::given(method("GET"))
318            .and(path("/discovery/lists/items/"))
319            .and(query_param("list_id", "list-1"))
320            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
321                "results": [{"id": "item-1", "symbol": "AAPL"}],
322                "next": next_url,
323                "previous": null
324            })))
325            .mount(&server)
326            .await;
327        Mock::given(method("GET"))
328            .and(path("/discovery/lists/items/"))
329            .and(query_param("cursor", "page-2"))
330            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
331                "results": [{"id": "item-2", "symbol": "MSFT"}],
332                "next": null,
333                "previous": null
334            })))
335            .mount(&server)
336            .await;
337        let (_dir, client) = client_for_server(&server.uri()).await;
338
339        let items = client.get_watchlist_items("Tech").await.unwrap();
340
341        let ids: Vec<_> = items.iter().filter_map(|item| item.id.as_deref()).collect();
342        assert_eq!(ids, ["item-1", "item-2"]);
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::bulk_watchlist_payload;
349    use crate::models::watchlist::{Watchlist, WatchlistItem};
350
351    #[test]
352    fn bulk_watchlist_payload_is_keyed_by_list_id() {
353        // The midlands bulk-edit endpoint requires the body to be an object
354        // keyed by the list ID, mapping to an array of ops. A top-level array
355        // ("failed operations") or an {list_id, items} object ("Expected a
356        // list of items but got type str") are both rejected live. Confirmed
357        // working against the real API for both create and delete.
358        let list_id = "2eda131c-04b4-4cbf-a0fa-4fcd48a84c5d";
359        let object_ids = vec![
360            "ad059c69-0c1c-4c6b-8322-f53f1bbd69d4".to_string(),
361            "450dfc6d-5510-4d40-abfb-f633b7d9be3e".to_string(),
362        ];
363        let payload = bulk_watchlist_payload(list_id, &object_ids, "create");
364
365        let ops = payload[list_id].as_array().expect("keyed array of ops");
366        assert_eq!(ops.len(), 2);
367        assert_eq!(ops[0]["object_type"], "instrument");
368        assert_eq!(ops[0]["object_id"], "ad059c69-0c1c-4c6b-8322-f53f1bbd69d4");
369        assert_eq!(ops[0]["operation"], "create");
370        assert_eq!(ops[1]["object_id"], "450dfc6d-5510-4d40-abfb-f633b7d9be3e");
371        // No top-level `items` / `list_id` keys: the list_id IS the key.
372        assert!(payload.get("items").is_none());
373        assert!(payload.get("list_id").is_none());
374    }
375
376    #[test]
377    fn bulk_watchlist_payload_supports_delete() {
378        let payload = bulk_watchlist_payload("L1", &["I1".to_string()], "delete");
379        assert_eq!(payload["L1"][0]["operation"], "delete");
380    }
381
382    #[test]
383    fn watchlist_deserializes_real_api_shape() {
384        let json = r#"{
385            "child_sort_direction": "ascending",
386            "child_sort_order": "custom",
387            "created_at": "2023-06-08T18:09:06.615545+00:00",
388            "display_description": null,
389            "display_name": "My First List",
390            "id": "2eda131c-04b4-4cbf-a0fa-4fcd48a84c5d",
391            "owner_type": "custom",
392            "parent_lists": [],
393            "read_permission": "private",
394            "updated_at": "2023-06-08T18:09:06.638995+00:00",
395            "allowed_object_types": ["currency_pair", "futures", "index", "instrument"],
396            "icon_emoji": "⚡",
397            "owner": "141fb69c-72c4-49c5-994b-1251039c8648",
398            "item_count": 16,
399            "child_info": {
400                "child_type": "item",
401                "children": []
402            },
403            "followed": true,
404            "default_expanded": true,
405            "related_lists": [],
406            "hero_images": null
407        }"#;
408        let list: Watchlist = serde_json::from_str(json).unwrap();
409        assert_eq!(list.display_name.as_deref(), Some("My First List"));
410        assert_eq!(
411            list.id.as_deref(),
412            Some("2eda131c-04b4-4cbf-a0fa-4fcd48a84c5d")
413        );
414        assert_eq!(list.owner_type.as_deref(), Some("custom"));
415        assert_eq!(list.icon_emoji.as_deref(), Some("⚡"));
416        assert_eq!(list.item_count, Some(16));
417        assert_eq!(list.followed, Some(true));
418        assert_eq!(list.allowed_object_types.as_ref().unwrap().len(), 4);
419        let child_info = list.child_info.unwrap();
420        assert_eq!(child_info.child_type.as_deref(), Some("item"));
421        assert_eq!(child_info.children.unwrap().len(), 0);
422    }
423
424    #[test]
425    fn watchlist_handles_missing_fields() {
426        let json = r#"{"display_name": "Empty"}"#;
427        let list: Watchlist = serde_json::from_str(json).unwrap();
428        assert_eq!(list.display_name.as_deref(), Some("Empty"));
429        assert!(list.child_info.is_none());
430        assert!(list.id.is_none());
431    }
432
433    #[test]
434    fn watchlist_item_deserializes_real_api_shape() {
435        let json = r#"{
436            "created_at": "2023-06-08T18:09:06.618468Z",
437            "id": "57f6d7f4-0824-435b-9428-1f483bfc7c28",
438            "list_id": "2eda131c-04b4-4cbf-a0fa-4fcd48a84c5d",
439            "object_id": "e39ed23a-7bd1-4587-b060-71988d9ef483",
440            "object_type": "instrument",
441            "owner_type": "custom",
442            "updated_at": "2023-06-08T18:09:06.618479Z",
443            "weight": "1.00000",
444            "market_cap": 1287984697449.2463,
445            "high": 364.5,
446            "low": 339.9101,
447            "volume": 78838049.0,
448            "average_volume": 67016803.259264,
449            "high_52_weeks": 498.83,
450            "low_52_weeks": 217.8,
451            "pe_ratio": 322.345174,
452            "name": "Tesla",
453            "open_positions": 0,
454            "symbol": "TSLA",
455            "state": "active",
456            "price": 341.87,
457            "bid_price": 341.8,
458            "ask_price": 341.9,
459            "previous_close": 346.65,
460            "one_day_dollar_change": -4.78,
461            "one_day_percent_change": -1.3789124477138324,
462            "holdings": false
463        }"#;
464        let item: WatchlistItem = serde_json::from_str(json).unwrap();
465        assert_eq!(item.symbol.as_deref(), Some("TSLA"));
466        assert_eq!(item.name.as_deref(), Some("Tesla"));
467        assert_eq!(item.object_type.as_deref(), Some("instrument"));
468        assert_eq!(
469            item.object_id.as_deref(),
470            Some("e39ed23a-7bd1-4587-b060-71988d9ef483")
471        );
472        assert!((item.price.unwrap() - 341.87).abs() < 0.01);
473        assert!((item.one_day_percent_change.unwrap() - (-1.3789124477138324)).abs() < 0.001);
474        assert_eq!(item.holdings, Some(false));
475        assert_eq!(item.open_positions, Some(0));
476    }
477}