diff --git a/watchlists/client.go b/watchlists/client.go new file mode 100644 index 0000000..23e81a4 --- /dev/null +++ b/watchlists/client.go @@ -0,0 +1,31 @@ +package watchlists + +import "s1d3sw1ped/robinhood-agentic-mcp/client" + +// Client wraps Robinhood watchlist MCP tools. +type Client struct { + c client.Caller +} + +// New returns a watchlists client that invokes tools through c. +func New(c client.Caller) *Client { + return &Client{c: c} +} + +// Tools returns the MCP names this package implements. +func Tools() []string { + return []string{ + toolLists, + toolItems, + toolOptionList, + toolPopular, + toolCreate, + toolUpdate, + toolFollow, + toolUnfollow, + toolAdd, + toolRemove, + toolAddOption, + toolRemoveOption, + } +} diff --git a/watchlists/watchlists.go b/watchlists/watchlists.go new file mode 100644 index 0000000..a7c7d90 --- /dev/null +++ b/watchlists/watchlists.go @@ -0,0 +1,324 @@ +package watchlists + +import ( + "context" + "encoding/json" + + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +const ( + toolLists = "get_watchlists" + toolItems = "get_watchlist_items" + toolOptionList = "get_option_watchlist" + toolPopular = "get_popular_watchlists" + toolCreate = "create_watchlist" + toolUpdate = "update_watchlist" + toolFollow = "follow_watchlist" + toolUnfollow = "unfollow_watchlist" + toolAdd = "add_to_watchlist" + toolRemove = "remove_from_watchlist" + toolAddOption = "add_option_to_watchlist" + toolRemoveOption = "remove_option_from_watchlist" +) + +// ListsRequest is the argument set for get_watchlists (none). +type ListsRequest struct{} + +// Watchlist is one list from get_watchlists. Title falls back to name. +type Watchlist struct { + ID string + Title string +} + +// ListsResult is the parsed get_watchlists payload. +type ListsResult struct { + Watchlists []Watchlist +} + +// ItemsRequest is the argument set for get_watchlist_items. +type ItemsRequest struct { + ListID string +} + +// Instrument is the nested instrument object on a watchlist item. +type Instrument struct { + Symbol string + Type string +} + +// Item is one row from get_watchlist_items. Object types are not filtered. +type Item struct { + Symbol string + ObjectType string + Instrument *Instrument +} + +// ItemsResult is the parsed get_watchlist_items payload. +type ItemsResult struct { + Items []Item +} + +// OptionListRequest is the argument set for get_option_watchlist (none). +type OptionListRequest struct{} + +// OptionListResult is the parsed get_option_watchlist payload. +type OptionListResult struct{} + +// PopularRequest is the argument set for get_popular_watchlists (none). +type PopularRequest struct{} + +// PopularResult is the parsed get_popular_watchlists payload. +type PopularResult struct{} + +// CreateRequest is the argument set for create_watchlist. +type CreateRequest struct { + DisplayName string + IconEmoji string + DisplayDescription string +} + +// UpdateRequest is the argument set for update_watchlist. +type UpdateRequest struct { + ListID string + DisplayName string + IconEmoji string + DisplayDescription string +} + +// ListIDRequest is a single list_id argument. +type ListIDRequest struct { + ListID string +} + +// AddRequest is the argument set for add_to_watchlist. +type AddRequest struct { + ListID string + Symbols []string + CurrencyPairIDs []string + IndexIDs []string +} + +// RemoveRequest is the argument set for remove_from_watchlist. +type RemoveRequest struct { + ListID string + Symbols []string + CurrencyPairIDs []string + IndexIDs []string +} + +// OptionMutateRequest is the argument set for add/remove option watchlist tools. +type OptionMutateRequest struct { + OptionIDs []string + PositionType string +} + +// Lists calls get_watchlists. +func (c *Client) Lists(ctx context.Context, req ListsRequest) (ListsResult, error) { + raw, err := c.c.Call(ctx, toolLists, map[string]any{}) + if err != nil { + return ListsResult{}, err + } + data := wire.Unwrap(raw) + var wrap struct { + Watchlists []watchlistJSON `json:"watchlists"` + } + if err := json.Unmarshal(data, &wrap); err != nil { + var list []watchlistJSON + if err := json.Unmarshal(data, &list); err != nil { + return ListsResult{}, client.ToolErrorf(toolLists, "parse: %w", err) + } + wrap.Watchlists = list + } + out := make([]Watchlist, 0, len(wrap.Watchlists)) + for _, w := range wrap.Watchlists { + title := w.Title + if title == "" { + title = w.Name + } + out = append(out, Watchlist{ID: w.ID, Title: title}) + } + return ListsResult{Watchlists: out}, nil +} + +// Items calls get_watchlist_items. Every object_type is kept. +func (c *Client) Items(ctx context.Context, req ItemsRequest) (ItemsResult, error) { + raw, err := c.c.Call(ctx, toolItems, listIDArgs(req.ListID)) + if err != nil { + return ItemsResult{}, err + } + data := wire.Unwrap(raw) + var wrap struct { + Items []itemJSON `json:"items"` + } + if err := json.Unmarshal(data, &wrap); err != nil { + var list []itemJSON + if err := json.Unmarshal(data, &list); err != nil { + return ItemsResult{}, client.ToolErrorf(toolItems, "parse: %w", err) + } + wrap.Items = list + } + out := make([]Item, 0, len(wrap.Items)) + for _, it := range wrap.Items { + sym := it.Symbol + typ := it.ObjectType + var inst *Instrument + if it.Instrument != nil { + inst = &Instrument{Symbol: it.Instrument.Symbol, Type: it.Instrument.Type} + if typ == "" { + typ = it.Instrument.Type + } + if sym == "" { + sym = it.Instrument.Symbol + } + } + out = append(out, Item{Symbol: sym, ObjectType: typ, Instrument: inst}) + } + return ItemsResult{Items: out}, nil +} + +// OptionList calls get_option_watchlist. +func (c *Client) OptionList(ctx context.Context, req OptionListRequest) (OptionListResult, error) { + var out OptionListResult + if err := c.parse(ctx, toolOptionList, map[string]any{}, &out); err != nil { + return OptionListResult{}, err + } + return out, nil +} + +// Popular calls get_popular_watchlists. +func (c *Client) Popular(ctx context.Context, req PopularRequest) (PopularResult, error) { + var out PopularResult + if err := c.parse(ctx, toolPopular, map[string]any{}, &out); err != nil { + return PopularResult{}, err + } + return out, nil +} + +// Create calls create_watchlist. +func (c *Client) Create(ctx context.Context, req CreateRequest) error { + args := map[string]any{} + if req.DisplayName != "" { + args["display_name"] = req.DisplayName + } + if req.IconEmoji != "" { + args["icon_emoji"] = req.IconEmoji + } + if req.DisplayDescription != "" { + args["display_description"] = req.DisplayDescription + } + _, err := c.c.Call(ctx, toolCreate, args) + return err +} + +// Update calls update_watchlist. +func (c *Client) Update(ctx context.Context, req UpdateRequest) error { + args := listIDArgs(req.ListID) + if req.DisplayName != "" { + args["display_name"] = req.DisplayName + } + if req.IconEmoji != "" { + args["icon_emoji"] = req.IconEmoji + } + if req.DisplayDescription != "" { + args["display_description"] = req.DisplayDescription + } + _, err := c.c.Call(ctx, toolUpdate, args) + return err +} + +// Follow calls follow_watchlist. +func (c *Client) Follow(ctx context.Context, req ListIDRequest) error { + _, err := c.c.Call(ctx, toolFollow, listIDArgs(req.ListID)) + return err +} + +// Unfollow calls unfollow_watchlist. +func (c *Client) Unfollow(ctx context.Context, req ListIDRequest) error { + _, err := c.c.Call(ctx, toolUnfollow, listIDArgs(req.ListID)) + return err +} + +// Add calls add_to_watchlist. +func (c *Client) Add(ctx context.Context, req AddRequest) error { + _, err := c.c.Call(ctx, toolAdd, mutateArgs(req.ListID, req.Symbols, req.CurrencyPairIDs, req.IndexIDs)) + return err +} + +// Remove calls remove_from_watchlist. +func (c *Client) Remove(ctx context.Context, req RemoveRequest) error { + _, err := c.c.Call(ctx, toolRemove, mutateArgs(req.ListID, req.Symbols, req.CurrencyPairIDs, req.IndexIDs)) + return err +} + +// AddOption calls add_option_to_watchlist. +func (c *Client) AddOption(ctx context.Context, req OptionMutateRequest) error { + _, err := c.c.Call(ctx, toolAddOption, optionArgs(req)) + return err +} + +// RemoveOption calls remove_option_from_watchlist. +func (c *Client) RemoveOption(ctx context.Context, req OptionMutateRequest) error { + _, err := c.c.Call(ctx, toolRemoveOption, optionArgs(req)) + return err +} + +type watchlistJSON struct { + ID string `json:"id"` + Title string `json:"title"` + Name string `json:"name"` +} + +type itemJSON struct { + Symbol string `json:"symbol"` + ObjectType string `json:"object_type"` + Instrument *struct { + Symbol string `json:"symbol"` + Type string `json:"type"` + } `json:"instrument"` +} + +func listIDArgs(id string) map[string]any { + args := map[string]any{} + if id != "" { + args["list_id"] = id + } + return args +} + +func mutateArgs(listID string, symbols, pairIDs, indexIDs []string) map[string]any { + args := listIDArgs(listID) + if len(symbols) > 0 { + args["symbols"] = symbols + } + if len(pairIDs) > 0 { + args["currency_pair_ids"] = pairIDs + } + if len(indexIDs) > 0 { + args["index_ids"] = indexIDs + } + return args +} + +func optionArgs(req OptionMutateRequest) map[string]any { + args := map[string]any{} + if len(req.OptionIDs) > 0 { + args["option_ids"] = req.OptionIDs + } + if req.PositionType != "" { + args["position_type"] = req.PositionType + } + return args +} + +func (c *Client) parse(ctx context.Context, tool string, args map[string]any, dest any) error { + raw, err := c.c.Call(ctx, tool, args) + if err != nil { + return err + } + if err := json.Unmarshal(wire.Unwrap(raw), dest); err != nil { + return client.ToolErrorf(tool, "parse: %w", err) + } + return nil +} diff --git a/watchlists/watchlists_test.go b/watchlists/watchlists_test.go new file mode 100644 index 0000000..a608190 --- /dev/null +++ b/watchlists/watchlists_test.go @@ -0,0 +1,307 @@ +package watchlists_test + +import ( + "context" + "encoding/json" + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest" + "s1d3sw1ped/robinhood-agentic-mcp/watchlists" +) + +func TestWatchlists_toolNames(t *testing.T) { + t.Parallel() + tests := []struct { + name string + call func(*watchlists.Client) error + wantName string + wantArgs map[string]any + }{ + { + name: "Lists", + call: func(c *watchlists.Client) error { + _, err := c.Lists(context.Background(), watchlists.ListsRequest{}) + return err + }, + wantName: "get_watchlists", + wantArgs: map[string]any{}, + }, + { + name: "Items", + call: func(c *watchlists.Client) error { + _, err := c.Items(context.Background(), watchlists.ItemsRequest{ListID: "wl-1"}) + return err + }, + wantName: "get_watchlist_items", + wantArgs: map[string]any{"list_id": "wl-1"}, + }, + { + name: "OptionList", + call: func(c *watchlists.Client) error { + _, err := c.OptionList(context.Background(), watchlists.OptionListRequest{}) + return err + }, + wantName: "get_option_watchlist", + wantArgs: map[string]any{}, + }, + { + name: "Popular", + call: func(c *watchlists.Client) error { + _, err := c.Popular(context.Background(), watchlists.PopularRequest{}) + return err + }, + wantName: "get_popular_watchlists", + wantArgs: map[string]any{}, + }, + { + name: "Create", + call: func(c *watchlists.Client) error { + return c.Create(context.Background(), watchlists.CreateRequest{ + DisplayName: "TRADEY", + IconEmoji: "📈", + DisplayDescription: "vwap book", + }) + }, + wantName: "create_watchlist", + wantArgs: map[string]any{ + "display_name": "TRADEY", + "icon_emoji": "📈", + "display_description": "vwap book", + }, + }, + { + name: "Update", + call: func(c *watchlists.Client) error { + return c.Update(context.Background(), watchlists.UpdateRequest{ + ListID: "wl-1", + DisplayName: "TRADEY", + IconEmoji: "📈", + DisplayDescription: "vwap book", + }) + }, + wantName: "update_watchlist", + wantArgs: map[string]any{ + "list_id": "wl-1", + "display_name": "TRADEY", + "icon_emoji": "📈", + "display_description": "vwap book", + }, + }, + { + name: "Follow", + call: func(c *watchlists.Client) error { + return c.Follow(context.Background(), watchlists.ListIDRequest{ListID: "wl-pop"}) + }, + wantName: "follow_watchlist", + wantArgs: map[string]any{"list_id": "wl-pop"}, + }, + { + name: "Unfollow", + call: func(c *watchlists.Client) error { + return c.Unfollow(context.Background(), watchlists.ListIDRequest{ListID: "wl-pop"}) + }, + wantName: "unfollow_watchlist", + wantArgs: map[string]any{"list_id": "wl-pop"}, + }, + { + name: "Add", + call: func(c *watchlists.Client) error { + return c.Add(context.Background(), watchlists.AddRequest{ + ListID: "wl-1", + Symbols: []string{"MU", "SPY"}, + CurrencyPairIDs: []string{"btc-1"}, + IndexIDs: []string{"idx-spx"}, + }) + }, + wantName: "add_to_watchlist", + wantArgs: map[string]any{ + "list_id": "wl-1", + "symbols": []string{"MU", "SPY"}, + "currency_pair_ids": []string{"btc-1"}, + "index_ids": []string{"idx-spx"}, + }, + }, + { + name: "Remove", + call: func(c *watchlists.Client) error { + return c.Remove(context.Background(), watchlists.RemoveRequest{ + ListID: "wl-1", + Symbols: []string{"MU"}, + CurrencyPairIDs: []string{"btc-1"}, + IndexIDs: []string{"idx-spx"}, + }) + }, + wantName: "remove_from_watchlist", + wantArgs: map[string]any{ + "list_id": "wl-1", + "symbols": []string{"MU"}, + "currency_pair_ids": []string{"btc-1"}, + "index_ids": []string{"idx-spx"}, + }, + }, + { + name: "AddOption", + call: func(c *watchlists.Client) error { + return c.AddOption(context.Background(), watchlists.OptionMutateRequest{ + OptionIDs: []string{"opt-1", "opt-2"}, + PositionType: "long", + }) + }, + wantName: "add_option_to_watchlist", + wantArgs: map[string]any{ + "option_ids": []string{"opt-1", "opt-2"}, + "position_type": "long", + }, + }, + { + name: "RemoveOption", + call: func(c *watchlists.Client) error { + return c.RemoveOption(context.Background(), watchlists.OptionMutateRequest{ + OptionIDs: []string{"opt-1"}, + PositionType: "short", + }) + }, + wantName: "remove_option_from_watchlist", + wantArgs: map[string]any{ + "option_ids": []string{"opt-1"}, + "position_type": "short", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var gotName string + var gotArgs map[string]any + c := watchlists.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + gotName, gotArgs = name, args + return json.RawMessage(`{}`), nil + })) + if err := tc.call(c); err != nil { + t.Fatal(err) + } + if gotName != tc.wantName { + t.Fatalf("%s %+v", gotName, gotArgs) + } + if diff := cmp.Diff(tc.wantArgs, gotArgs); diff != "" { + t.Fatal(diff) + } + }) + } +} + +func TestTools(t *testing.T) { + t.Parallel() + want := []string{ + "add_option_to_watchlist", + "add_to_watchlist", + "create_watchlist", + "follow_watchlist", + "get_option_watchlist", + "get_popular_watchlists", + "get_watchlist_items", + "get_watchlists", + "remove_from_watchlist", + "remove_option_from_watchlist", + "unfollow_watchlist", + "update_watchlist", + } + got := append([]string(nil), watchlists.Tools()...) + sort.Strings(got) + if diff := cmp.Diff(want, got); diff != "" { + t.Fatal(diff) + } +} + +func TestLists_rhntest(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.Set("get_watchlists", json.RawMessage(`{"watchlists":[{"id":"wl-1","title":"TRADEY"}]}`)) + c := watchlists.New(&client.Client{URL: s.URL}) + got, err := c.Lists(context.Background(), watchlists.ListsRequest{}) + if err != nil { + t.Fatal(err) + } + if len(got.Watchlists) != 1 || got.Watchlists[0].ID != "wl-1" || got.Watchlists[0].Title != "TRADEY" { + t.Fatalf("%+v", got) + } + if s.LastName() != "get_watchlists" { + t.Fatalf("%s", s.LastName()) + } + if diff := cmp.Diff(map[string]any{}, s.LastArgs()); diff != "" { + t.Fatal(diff) + } +} + +func TestItems_rhntest(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.Set("get_watchlist_items", json.RawMessage(`{"items":[{"symbol":"MU","object_type":"equity"}]}`)) + c := watchlists.New(&client.Client{URL: s.URL}) + got, err := c.Items(context.Background(), watchlists.ItemsRequest{ListID: "wl-1"}) + if err != nil { + t.Fatal(err) + } + if len(got.Items) != 1 || got.Items[0].Symbol != "MU" || got.Items[0].ObjectType != "equity" { + t.Fatalf("%+v", got) + } + if s.LastName() != "get_watchlist_items" { + t.Fatalf("%s", s.LastName()) + } + want := map[string]any{"list_id": "wl-1"} + if diff := cmp.Diff(want, s.LastArgs()); diff != "" { + t.Fatal(diff) + } +} + +func TestLists_titleFallsBackToName(t *testing.T) { + t.Parallel() + c := watchlists.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + return json.RawMessage(`{"watchlists":[{"id":"wl-1","title":"tradey"},{"id":"wl-2","name":"Other"}]}`), nil + })) + got, err := c.Lists(context.Background(), watchlists.ListsRequest{}) + if err != nil { + t.Fatal(err) + } + want := watchlists.ListsResult{ + Watchlists: []watchlists.Watchlist{ + {ID: "wl-1", Title: "tradey"}, + {ID: "wl-2", Title: "Other"}, + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Fatal(diff) + } +} + +func TestItems_keepsObjectTypesAndNestedSymbol(t *testing.T) { + t.Parallel() + c := watchlists.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + return json.RawMessage(`{"items":[ + {"symbol":"MU","object_type":"equity"}, + {"symbol":"BTC","object_type":"crypto"}, + {"instrument":{"symbol":"SPY","type":"etf"}} + ]}`), nil + })) + got, err := c.Items(context.Background(), watchlists.ItemsRequest{ListID: "wl-1"}) + if err != nil { + t.Fatal(err) + } + want := watchlists.ItemsResult{ + Items: []watchlists.Item{ + {Symbol: "MU", ObjectType: "equity"}, + {Symbol: "BTC", ObjectType: "crypto"}, + { + Symbol: "SPY", + ObjectType: "etf", + Instrument: &watchlists.Instrument{Symbol: "SPY", Type: "etf"}, + }, + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Fatal(diff) + } +}