diff --git a/accounts/accounts.go b/accounts/accounts.go new file mode 100644 index 0000000..fc82930 --- /dev/null +++ b/accounts/accounts.go @@ -0,0 +1,274 @@ +package accounts + +import ( + "context" + "encoding/json" + + decimal "github.com/alpacahq/alpacadecimal" + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +const ( + toolAccounts = "get_accounts" + toolPortfolio = "get_portfolio" + toolRealizedPnL = "get_realized_pnl" + toolPnLTradeHistory = "get_pnl_trade_history" + toolLimitedMarginUpgrade = "get_limited_margin_upgrade_info" + toolOptionLevelUpgrade = "get_option_level_upgrade_info" + toolCryptoOnboarding = "get_crypto_account_onboarding_info" + toolSearch = "search" +) + +// AccountsRequest is the argument set for get_accounts (none). +type AccountsRequest struct{} + +// Account is one brokerage account from get_accounts. +type Account struct { + AccountNumber string + ID string + RHSAccountNumber string + Type string + AgenticAllowed bool + Cash bool + BuyingPower *decimal.Decimal +} + +// AccountsResult is the parsed get_accounts payload. +type AccountsResult struct { + Accounts []Account +} + +// PortfolioRequest is the argument set for get_portfolio. +type PortfolioRequest struct { + AccountNumber string +} + +// PortfolioResult is the parsed get_portfolio payload. +type PortfolioResult struct { + BuyingPower decimal.Decimal +} + +// RealizedPnLRequest is the argument set for get_realized_pnl. +type RealizedPnLRequest struct { + AccountNumber string + Span string + StartDate string + EndDate string + AssetClasses []string + DisplayCurrency string + Timezone string +} + +// RealizedPnLResult is the parsed get_realized_pnl payload. +type RealizedPnLResult struct{} + +// PnLTradeHistoryRequest is the argument set for get_pnl_trade_history. +type PnLTradeHistoryRequest struct { + AccountNumber string + Span string + Symbol string + Cursor string +} + +// PnLTradeHistoryResult is the parsed get_pnl_trade_history payload. +type PnLTradeHistoryResult struct{} + +// AccountNumberRequest is a single account_number argument. +type AccountNumberRequest struct { + AccountNumber string +} + +// UpgradeInfoResult is the parsed upgrade-info payload. +type UpgradeInfoResult struct{} + +// OnboardingInfoResult is the parsed crypto onboarding payload. +type OnboardingInfoResult struct{} + +// SearchRequest is the argument set for search. +type SearchRequest struct { + Query string + AssetType string + Limit int +} + +// SearchResult is the parsed search payload. +type SearchResult struct{} + +// Accounts calls get_accounts and returns every account (no IRA/margin filter). +func (c *Client) Accounts(ctx context.Context, req AccountsRequest) (AccountsResult, error) { + raw, err := c.c.Call(ctx, toolAccounts, map[string]any{}) + if err != nil { + return AccountsResult{}, err + } + var wrap struct { + Accounts []struct { + AccountNumber string `json:"account_number"` + ID string `json:"id"` + RHSAccountNumber string `json:"rhs_account_number"` + Type string `json:"type"` + AgenticAllowed bool `json:"agentic_allowed"` + Cash bool `json:"cash"` + BuyingPower any `json:"buying_power"` + } `json:"accounts"` + } + if err := json.Unmarshal(wire.Unwrap(raw), &wrap); err != nil { + return AccountsResult{}, client.ToolErrorf(toolAccounts, "parse: %w", err) + } + out := make([]Account, 0, len(wrap.Accounts)) + for _, a := range wrap.Accounts { + bp, err := wire.DecOpt(a.BuyingPower) + if err != nil { + return AccountsResult{}, client.ToolErrorf(toolAccounts, "parse: %w", err) + } + out = append(out, Account{ + AccountNumber: a.AccountNumber, + ID: a.ID, + RHSAccountNumber: a.RHSAccountNumber, + Type: a.Type, + AgenticAllowed: a.AgenticAllowed, + Cash: a.Cash, + BuyingPower: bp, + }) + } + return AccountsResult{Accounts: out}, nil +} + +// Portfolio calls get_portfolio. +func (c *Client) Portfolio(ctx context.Context, req PortfolioRequest) (PortfolioResult, error) { + args := map[string]any{} + if req.AccountNumber != "" { + args["account_number"] = req.AccountNumber + } + raw, err := c.c.Call(ctx, toolPortfolio, args) + if err != nil { + return PortfolioResult{}, err + } + var loose struct { + BuyingPower struct { + BuyingPower any `json:"buying_power"` + } `json:"buying_power"` + } + if err := json.Unmarshal(wire.Unwrap(raw), &loose); err != nil { + return PortfolioResult{}, client.ToolErrorf(toolPortfolio, "parse: %w", err) + } + bp, err := wire.Dec(loose.BuyingPower.BuyingPower) + if err != nil { + return PortfolioResult{}, client.ToolErrorf(toolPortfolio, "parse: %w", err) + } + return PortfolioResult{BuyingPower: bp}, nil +} + +// RealizedPnL calls get_realized_pnl. +func (c *Client) RealizedPnL(ctx context.Context, req RealizedPnLRequest) (RealizedPnLResult, error) { + args := map[string]any{} + if req.AccountNumber != "" { + args["account_number"] = req.AccountNumber + } + if req.Span != "" { + args["span"] = req.Span + } + if req.StartDate != "" { + args["start_date"] = req.StartDate + } + if req.EndDate != "" { + args["end_date"] = req.EndDate + } + if len(req.AssetClasses) > 0 { + args["asset_classes"] = req.AssetClasses + } + if req.DisplayCurrency != "" { + args["display_currency"] = req.DisplayCurrency + } + if req.Timezone != "" { + args["timezone"] = req.Timezone + } + var out RealizedPnLResult + if err := c.parse(ctx, toolRealizedPnL, args, &out); err != nil { + return RealizedPnLResult{}, err + } + return out, nil +} + +// PnLTradeHistory calls get_pnl_trade_history. +func (c *Client) PnLTradeHistory(ctx context.Context, req PnLTradeHistoryRequest) (PnLTradeHistoryResult, error) { + args := map[string]any{} + if req.AccountNumber != "" { + args["account_number"] = req.AccountNumber + } + if req.Span != "" { + args["span"] = req.Span + } + if req.Symbol != "" { + args["symbol"] = req.Symbol + } + if req.Cursor != "" { + args["cursor"] = req.Cursor + } + var out PnLTradeHistoryResult + if err := c.parse(ctx, toolPnLTradeHistory, args, &out); err != nil { + return PnLTradeHistoryResult{}, err + } + return out, nil +} + +// LimitedMarginUpgradeInfo calls get_limited_margin_upgrade_info. +func (c *Client) LimitedMarginUpgradeInfo(ctx context.Context, req AccountNumberRequest) (UpgradeInfoResult, error) { + return c.upgradeInfo(ctx, toolLimitedMarginUpgrade, req) +} + +// OptionLevelUpgradeInfo calls get_option_level_upgrade_info. +func (c *Client) OptionLevelUpgradeInfo(ctx context.Context, req AccountNumberRequest) (UpgradeInfoResult, error) { + return c.upgradeInfo(ctx, toolOptionLevelUpgrade, req) +} + +func (c *Client) upgradeInfo(ctx context.Context, tool string, req AccountNumberRequest) (UpgradeInfoResult, error) { + args := map[string]any{} + if req.AccountNumber != "" { + args["account_number"] = req.AccountNumber + } + var out UpgradeInfoResult + if err := c.parse(ctx, tool, args, &out); err != nil { + return UpgradeInfoResult{}, err + } + return out, nil +} + +// CryptoOnboardingInfo calls get_crypto_account_onboarding_info. +func (c *Client) CryptoOnboardingInfo(ctx context.Context, req struct{}) (OnboardingInfoResult, error) { + var out OnboardingInfoResult + if err := c.parse(ctx, toolCryptoOnboarding, map[string]any{}, &out); err != nil { + return OnboardingInfoResult{}, err + } + return out, nil +} + +// Search calls search. +func (c *Client) Search(ctx context.Context, req SearchRequest) (SearchResult, error) { + args := map[string]any{} + if req.Query != "" { + args["query"] = req.Query + } + if req.AssetType != "" { + args["asset_type"] = req.AssetType + } + if req.Limit != 0 { + args["limit"] = req.Limit + } + var out SearchResult + if err := c.parse(ctx, toolSearch, args, &out); err != nil { + return SearchResult{}, err + } + return out, nil +} + +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/accounts/accounts_test.go b/accounts/accounts_test.go new file mode 100644 index 0000000..cb068d1 --- /dev/null +++ b/accounts/accounts_test.go @@ -0,0 +1,210 @@ +package accounts_test + +import ( + "context" + "encoding/json" + "os" + "sort" + "testing" + + decimal "github.com/alpacahq/alpacadecimal" + "github.com/google/go-cmp/cmp" + "s1d3sw1ped/robinhood-agentic-mcp/accounts" + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest" +) + +func TestAccounts_toolNames(t *testing.T) { + t.Parallel() + tests := []struct { + name string + call func(*accounts.Client) error + wantName string + wantArgs map[string]any + }{ + { + name: "Accounts", + call: func(c *accounts.Client) error { + _, err := c.Accounts(context.Background(), accounts.AccountsRequest{}) + return err + }, + wantName: "get_accounts", + wantArgs: map[string]any{}, + }, + { + name: "Portfolio", + call: func(c *accounts.Client) error { + _, err := c.Portfolio(context.Background(), accounts.PortfolioRequest{AccountNumber: "acct-1"}) + return err + }, + wantName: "get_portfolio", + wantArgs: map[string]any{"account_number": "acct-1"}, + }, + { + name: "RealizedPnL", + call: func(c *accounts.Client) error { + _, err := c.RealizedPnL(context.Background(), accounts.RealizedPnLRequest{ + AccountNumber: "acct-1", + Span: "month", + StartDate: "2026-01-01", + EndDate: "2026-01-31", + AssetClasses: []string{"equity"}, + DisplayCurrency: "USD", + Timezone: "America/New_York", + }) + return err + }, + wantName: "get_realized_pnl", + wantArgs: map[string]any{ + "account_number": "acct-1", + "span": "month", + "start_date": "2026-01-01", + "end_date": "2026-01-31", + "asset_classes": []string{"equity"}, + "display_currency": "USD", + "timezone": "America/New_York", + }, + }, + { + name: "PnLTradeHistory", + call: func(c *accounts.Client) error { + _, err := c.PnLTradeHistory(context.Background(), accounts.PnLTradeHistoryRequest{ + AccountNumber: "acct-1", + Span: "week", + Symbol: "MU", + Cursor: "c1", + }) + return err + }, + wantName: "get_pnl_trade_history", + wantArgs: map[string]any{ + "account_number": "acct-1", + "span": "week", + "symbol": "MU", + "cursor": "c1", + }, + }, + { + name: "LimitedMarginUpgradeInfo", + call: func(c *accounts.Client) error { + _, err := c.LimitedMarginUpgradeInfo(context.Background(), accounts.AccountNumberRequest{AccountNumber: "acct-1"}) + return err + }, + wantName: "get_limited_margin_upgrade_info", + wantArgs: map[string]any{"account_number": "acct-1"}, + }, + { + name: "OptionLevelUpgradeInfo", + call: func(c *accounts.Client) error { + _, err := c.OptionLevelUpgradeInfo(context.Background(), accounts.AccountNumberRequest{AccountNumber: "acct-1"}) + return err + }, + wantName: "get_option_level_upgrade_info", + wantArgs: map[string]any{"account_number": "acct-1"}, + }, + { + name: "CryptoOnboardingInfo", + call: func(c *accounts.Client) error { + _, err := c.CryptoOnboardingInfo(context.Background(), struct{}{}) + return err + }, + wantName: "get_crypto_account_onboarding_info", + wantArgs: map[string]any{}, + }, + { + name: "Search", + call: func(c *accounts.Client) error { + _, err := c.Search(context.Background(), accounts.SearchRequest{ + Query: "apple", + AssetType: "instrument", + Limit: 5, + }) + return err + }, + wantName: "search", + wantArgs: map[string]any{ + "query": "apple", + "asset_type": "instrument", + "limit": 5, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var gotName string + var gotArgs map[string]any + c := accounts.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{ + "get_accounts", + "get_crypto_account_onboarding_info", + "get_limited_margin_upgrade_info", + "get_option_level_upgrade_info", + "get_pnl_trade_history", + "get_portfolio", + "get_realized_pnl", + "search", + } + got := append([]string(nil), accounts.Tools()...) + sort.Strings(got) + if diff := cmp.Diff(want, got); diff != "" { + t.Fatal(diff) + } +} + +func TestAccounts_rhntest(t *testing.T) { + t.Parallel() + raw, err := os.ReadFile("testdata/accounts.json") + if err != nil { + t.Fatal(err) + } + s := rhntest.New(t) + s.Set("get_accounts", raw) + c := accounts.New(&client.Client{URL: s.URL}) + got, err := c.Accounts(context.Background(), accounts.AccountsRequest{}) + if err != nil { + t.Fatal(err) + } + if len(got.Accounts) != 1 { + t.Fatalf("%+v", got) + } + a := got.Accounts[0] + if a.ID != "acct-1" || a.Type != "cash" || !a.AgenticAllowed || !a.Cash { + t.Fatalf("%+v", a) + } + if a.BuyingPower == nil || !a.BuyingPower.Equal(decimal.RequireFromString("1000")) { + t.Fatalf("buying power %v", a.BuyingPower) + } +} + +func TestPortfolio_rhntest(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.Set("get_portfolio", json.RawMessage(`{"data":{"buying_power":{"buying_power":"1000.0"}}}`)) + c := accounts.New(&client.Client{URL: s.URL}) + got, err := c.Portfolio(context.Background(), accounts.PortfolioRequest{AccountNumber: "acct-1"}) + if err != nil { + t.Fatal(err) + } + if !got.BuyingPower.Equal(decimal.RequireFromString("1000.0")) { + t.Fatalf("buying power %s", got.BuyingPower) + } +} diff --git a/accounts/client.go b/accounts/client.go new file mode 100644 index 0000000..ee52be9 --- /dev/null +++ b/accounts/client.go @@ -0,0 +1,27 @@ +package accounts + +import "s1d3sw1ped/robinhood-agentic-mcp/client" + +// Client wraps Robinhood account MCP tools. +type Client struct { + c client.Caller +} + +// New returns an accounts 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{ + toolAccounts, + toolPortfolio, + toolRealizedPnL, + toolPnLTradeHistory, + toolLimitedMarginUpgrade, + toolOptionLevelUpgrade, + toolCryptoOnboarding, + toolSearch, + } +} diff --git a/accounts/testdata/accounts.json b/accounts/testdata/accounts.json new file mode 100644 index 0000000..b6322d4 --- /dev/null +++ b/accounts/testdata/accounts.json @@ -0,0 +1 @@ +{"accounts":[{"id":"acct-1","type":"cash","agentic_allowed":true,"cash":true,"buying_power":1000.0}]} diff --git a/go.mod b/go.mod index 72cc7f7..c857744 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/alpacahq/alpacadecimal v0.0.9 + github.com/google/go-cmp v0.7.0 github.com/modelcontextprotocol/go-sdk v1.7.0 golang.org/x/oauth2 v0.35.0 )