use crate::api::paths;
use crate::client::RobinhoodClient;
use crate::models::watchlist::{Watchlist, WatchlistItem};
use crate::{Result, RhoodError};
impl RobinhoodClient {
pub async fn get_watchlists(&self) -> Result<Vec<Watchlist>> {
self.get_paginated(
&self.api_url(paths::WATCHLISTS),
&[("owner_type", "custom")],
)
.await
}
pub async fn get_watchlist(&self, name_or_id: &str) -> Result<Watchlist> {
let lists = self.get_watchlists().await?;
lists
.iter()
.find(|list| {
list.display_name
.as_deref()
.is_some_and(|name| name.eq_ignore_ascii_case(name_or_id))
})
.or_else(|| {
lists
.iter()
.find(|list| list.id.as_deref() == Some(name_or_id))
})
.cloned()
.ok_or_else(|| {
RhoodError::InvalidParameter(format!("Watchlist not found: {name_or_id}"))
})
}
pub async fn get_watchlist_items(&self, name_or_id: &str) -> Result<Vec<WatchlistItem>> {
let list = self.get_watchlist(name_or_id).await?;
let is_options_only = list.allowed_object_types.as_ref().is_some_and(|types| {
types
.iter()
.all(|object_type| object_type == "option_strategy")
});
if is_options_only {
let name = list.display_name.as_deref().unwrap_or("this watchlist");
return Err(RhoodError::InvalidParameter(format!(
"'{name}' is an options watchlist and cannot be listed via the discovery API"
)));
}
let list_id = list
.id
.as_deref()
.ok_or_else(|| RhoodError::InvalidParameter("Watchlist missing ID".into()))?;
self.get_paginated(
&self.api_url(paths::WATCHLIST_ITEMS),
&[("list_id", list_id)],
)
.await
}
pub async fn add_to_watchlist(&self, name: &str, symbols: &[&str]) -> Result<()> {
self.require_writable()?;
let list = self.get_watchlist(name).await?;
let list_id = list
.id
.clone()
.ok_or_else(|| RhoodError::InvalidParameter("Watchlist missing ID".into()))?;
let mut object_ids = Vec::with_capacity(symbols.len());
for symbol in symbols {
let instrument = self
.cached_instrument(symbol)
.await?
.ok_or_else(|| RhoodError::InvalidSymbol((*symbol).to_string()))?;
let instrument_id = instrument
.id
.clone()
.ok_or_else(|| RhoodError::InvalidSymbol((*symbol).to_string()))?;
object_ids.push(instrument_id);
}
if object_ids.is_empty() {
return Ok(());
}
self.bulk_watchlist_edit(&list_id, &object_ids, "create")
.await?;
Ok(())
}
pub async fn remove_from_watchlist(&self, name: &str, symbols: &[&str]) -> Result<usize> {
self.require_writable()?;
let list = self.get_watchlist(name).await?;
let list_id = list
.id
.clone()
.ok_or_else(|| RhoodError::InvalidParameter("Watchlist missing ID".into()))?;
let items = self.get_watchlist_items(name).await?;
let object_ids: Vec<String> = symbols
.iter()
.filter_map(|symbol| {
items
.iter()
.find(|item| {
item.symbol
.as_deref()
.is_some_and(|item_symbol| item_symbol.eq_ignore_ascii_case(symbol))
})
.and_then(|item| item.object_id.clone())
})
.collect();
if object_ids.is_empty() {
return Ok(0);
}
self.bulk_watchlist_edit(&list_id, &object_ids, "delete")
.await?;
Ok(object_ids.len())
}
async fn bulk_watchlist_edit(
&self,
list_id: &str,
object_ids: &[String],
operation: &str,
) -> Result<()> {
let payload = bulk_watchlist_payload(list_id, object_ids, operation);
let _: serde_json::Value = self
.post_json(&self.api_url(paths::WATCHLIST_ITEMS_WRITE), &payload)
.await?;
Ok(())
}
}
fn bulk_watchlist_payload(
list_id: &str,
object_ids: &[String],
operation: &str,
) -> serde_json::Value {
let ops: Vec<serde_json::Value> = object_ids
.iter()
.map(|object_id| {
serde_json::json!({
"object_type": "instrument",
"object_id": object_id,
"operation": operation,
})
})
.collect();
serde_json::json!({ list_id: ops })
}
#[cfg(test)]
mod endpoint_tests {
use crate::client::RobinhoodClient;
use crate::config::RhoodConfig;
use secrecy::SecretString;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
async fn client_for_server(base_url: &str) -> (tempfile::TempDir, RobinhoodClient) {
let dir = tempfile::tempdir().unwrap();
let mut config = RhoodConfig::default();
config.auth.token_cache_path = dir
.path()
.join("nonexistent-token.json")
.to_str()
.unwrap()
.to_string();
config.api.base_url = base_url.to_string();
let client = RobinhoodClient::with_config(config).unwrap();
client
.inject_test_auth(
SecretString::from("access-token"),
"Bearer".to_string(),
SecretString::from("refresh-token"),
)
.await;
(dir, client)
}
#[tokio::test]
async fn get_watchlists_follows_next_page_in_order() {
let server = MockServer::start().await;
let next_url = format!("{}/midlands/lists/?cursor=page-2", server.uri());
Mock::given(method("GET"))
.and(path("/midlands/lists/"))
.and(query_param("owner_type", "custom"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "list-1", "display_name": "First"}],
"next": next_url,
"previous": null
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/midlands/lists/"))
.and(query_param("cursor", "page-2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "list-2", "display_name": "Second"}],
"next": null,
"previous": null
})))
.mount(&server)
.await;
let (_dir, client) = client_for_server(&server.uri()).await;
let lists = client.get_watchlists().await.unwrap();
let ids: Vec<_> = lists.iter().filter_map(|list| list.id.as_deref()).collect();
assert_eq!(ids, ["list-1", "list-2"]);
}
#[tokio::test]
async fn get_watchlist_finds_match_on_next_page() {
let server = MockServer::start().await;
let next_url = format!("{}/midlands/lists/?cursor=page-2", server.uri());
Mock::given(method("GET"))
.and(path("/midlands/lists/"))
.and(query_param("owner_type", "custom"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "list-1", "display_name": "First"}],
"next": next_url,
"previous": null
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/midlands/lists/"))
.and(query_param("cursor", "page-2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "list-2", "display_name": "Later"}],
"next": null,
"previous": null
})))
.mount(&server)
.await;
let (_dir, client) = client_for_server(&server.uri()).await;
let list = client.get_watchlist("Later").await.unwrap();
assert_eq!(list.id.as_deref(), Some("list-2"));
}
#[tokio::test]
async fn get_watchlist_items_follows_next_page_in_order() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/midlands/lists/"))
.and(query_param("owner_type", "custom"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "list-1", "display_name": "Tech"}],
"next": null,
"previous": null
})))
.mount(&server)
.await;
let next_url = format!("{}/discovery/lists/items/?cursor=page-2", server.uri());
Mock::given(method("GET"))
.and(path("/discovery/lists/items/"))
.and(query_param("list_id", "list-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "item-1", "symbol": "AAPL"}],
"next": next_url,
"previous": null
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/discovery/lists/items/"))
.and(query_param("cursor", "page-2"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"results": [{"id": "item-2", "symbol": "MSFT"}],
"next": null,
"previous": null
})))
.mount(&server)
.await;
let (_dir, client) = client_for_server(&server.uri()).await;
let items = client.get_watchlist_items("Tech").await.unwrap();
let ids: Vec<_> = items.iter().filter_map(|item| item.id.as_deref()).collect();
assert_eq!(ids, ["item-1", "item-2"]);
}
}
#[cfg(test)]
mod tests {
use super::bulk_watchlist_payload;
use crate::models::watchlist::{Watchlist, WatchlistItem};
#[test]
fn bulk_watchlist_payload_is_keyed_by_list_id() {
let list_id = "2eda131c-04b4-4cbf-a0fa-4fcd48a84c5d";
let object_ids = vec![
"ad059c69-0c1c-4c6b-8322-f53f1bbd69d4".to_string(),
"450dfc6d-5510-4d40-abfb-f633b7d9be3e".to_string(),
];
let payload = bulk_watchlist_payload(list_id, &object_ids, "create");
let ops = payload[list_id].as_array().expect("keyed array of ops");
assert_eq!(ops.len(), 2);
assert_eq!(ops[0]["object_type"], "instrument");
assert_eq!(ops[0]["object_id"], "ad059c69-0c1c-4c6b-8322-f53f1bbd69d4");
assert_eq!(ops[0]["operation"], "create");
assert_eq!(ops[1]["object_id"], "450dfc6d-5510-4d40-abfb-f633b7d9be3e");
assert!(payload.get("items").is_none());
assert!(payload.get("list_id").is_none());
}
#[test]
fn bulk_watchlist_payload_supports_delete() {
let payload = bulk_watchlist_payload("L1", &["I1".to_string()], "delete");
assert_eq!(payload["L1"][0]["operation"], "delete");
}
#[test]
fn watchlist_deserializes_real_api_shape() {
let json = r#"{
"child_sort_direction": "ascending",
"child_sort_order": "custom",
"created_at": "2023-06-08T18:09:06.615545+00:00",
"display_description": null,
"display_name": "My First List",
"id": "2eda131c-04b4-4cbf-a0fa-4fcd48a84c5d",
"owner_type": "custom",
"parent_lists": [],
"read_permission": "private",
"updated_at": "2023-06-08T18:09:06.638995+00:00",
"allowed_object_types": ["currency_pair", "futures", "index", "instrument"],
"icon_emoji": "⚡",
"owner": "141fb69c-72c4-49c5-994b-1251039c8648",
"item_count": 16,
"child_info": {
"child_type": "item",
"children": []
},
"followed": true,
"default_expanded": true,
"related_lists": [],
"hero_images": null
}"#;
let list: Watchlist = serde_json::from_str(json).unwrap();
assert_eq!(list.display_name.as_deref(), Some("My First List"));
assert_eq!(
list.id.as_deref(),
Some("2eda131c-04b4-4cbf-a0fa-4fcd48a84c5d")
);
assert_eq!(list.owner_type.as_deref(), Some("custom"));
assert_eq!(list.icon_emoji.as_deref(), Some("⚡"));
assert_eq!(list.item_count, Some(16));
assert_eq!(list.followed, Some(true));
assert_eq!(list.allowed_object_types.as_ref().unwrap().len(), 4);
let child_info = list.child_info.unwrap();
assert_eq!(child_info.child_type.as_deref(), Some("item"));
assert_eq!(child_info.children.unwrap().len(), 0);
}
#[test]
fn watchlist_handles_missing_fields() {
let json = r#"{"display_name": "Empty"}"#;
let list: Watchlist = serde_json::from_str(json).unwrap();
assert_eq!(list.display_name.as_deref(), Some("Empty"));
assert!(list.child_info.is_none());
assert!(list.id.is_none());
}
#[test]
fn watchlist_item_deserializes_real_api_shape() {
let json = r#"{
"created_at": "2023-06-08T18:09:06.618468Z",
"id": "57f6d7f4-0824-435b-9428-1f483bfc7c28",
"list_id": "2eda131c-04b4-4cbf-a0fa-4fcd48a84c5d",
"object_id": "e39ed23a-7bd1-4587-b060-71988d9ef483",
"object_type": "instrument",
"owner_type": "custom",
"updated_at": "2023-06-08T18:09:06.618479Z",
"weight": "1.00000",
"market_cap": 1287984697449.2463,
"high": 364.5,
"low": 339.9101,
"volume": 78838049.0,
"average_volume": 67016803.259264,
"high_52_weeks": 498.83,
"low_52_weeks": 217.8,
"pe_ratio": 322.345174,
"name": "Tesla",
"open_positions": 0,
"symbol": "TSLA",
"state": "active",
"price": 341.87,
"bid_price": 341.8,
"ask_price": 341.9,
"previous_close": 346.65,
"one_day_dollar_change": -4.78,
"one_day_percent_change": -1.3789124477138324,
"holdings": false
}"#;
let item: WatchlistItem = serde_json::from_str(json).unwrap();
assert_eq!(item.symbol.as_deref(), Some("TSLA"));
assert_eq!(item.name.as_deref(), Some("Tesla"));
assert_eq!(item.object_type.as_deref(), Some("instrument"));
assert_eq!(
item.object_id.as_deref(),
Some("e39ed23a-7bd1-4587-b060-71988d9ef483")
);
assert!((item.price.unwrap() - 341.87).abs() < 0.01);
assert!((item.one_day_percent_change.unwrap() - (-1.3789124477138324)).abs() < 0.001);
assert_eq!(item.holdings, Some(false));
assert_eq!(item.open_positions, Some(0));
}
}