From 47387719bfc0915e307e53afdb93c766b5db6121 Mon Sep 17 00:00:00 2001 From: Justin Harms Date: Tue, 1 Sep 2026 13:23:05 -0500 Subject: [PATCH] feat: add market data MCP methods --- market/client.go | 29 ++++ market/market.go | 308 ++++++++++++++++++++++++++++++++++++++++++ market/market_test.go | 243 +++++++++++++++++++++++++++++++++ 3 files changed, 580 insertions(+) create mode 100644 market/client.go create mode 100644 market/market.go create mode 100644 market/market_test.go diff --git a/market/client.go b/market/client.go new file mode 100644 index 0000000..21d78d4 --- /dev/null +++ b/market/client.go @@ -0,0 +1,29 @@ +package market + +import "s1d3sw1ped/robinhood-agentic-mcp/client" + +// Client wraps Robinhood market-data MCP tools. +type Client struct { + c client.Caller +} + +// New returns a market 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{ + toolIndexes, + toolIndexQuotes, + toolIndexHistoricals, + toolFinancials, + toolEarningsResults, + toolEarningsCalendar, + toolSECFilingIndex, + toolSECFiling, + toolSECFilingFacts, + toolSECFilingFactsCatalog, + } +} diff --git a/market/market.go b/market/market.go new file mode 100644 index 0000000..8034e1a --- /dev/null +++ b/market/market.go @@ -0,0 +1,308 @@ +package market + +import ( + "context" + "encoding/json" + "time" + + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +const ( + toolIndexes = "get_indexes" + toolIndexQuotes = "get_index_quotes" + toolIndexHistoricals = "get_index_historicals" + toolFinancials = "get_financials" + toolEarningsResults = "get_earnings_results" + toolEarningsCalendar = "get_earnings_calendar" + toolSECFilingIndex = "get_sec_filing_index" + toolSECFiling = "get_sec_filing" + toolSECFilingFacts = "get_sec_filing_facts" + toolSECFilingFactsCatalog = "get_sec_filing_facts_catalog" +) + +// IndexesRequest is the argument set for get_indexes. +type IndexesRequest struct { + Symbols string // comma-separated; live schema is a string, not an array +} + +// IndexesResult is the parsed get_indexes payload. +type IndexesResult struct{} + +// IndexQuotesRequest is the argument set for get_index_quotes. +type IndexQuotesRequest struct { + InstrumentIDs []string +} + +// IndexQuotesResult is the parsed get_index_quotes payload. +type IndexQuotesResult struct{} + +// IndexHistoricalsRequest is the argument set for get_index_historicals. +type IndexHistoricalsRequest struct { + InstrumentIDs []string + StartTime time.Time + EndTime time.Time + Interval string // required — no hidden default +} + +// IndexHistoricalsResult is the parsed get_index_historicals payload. +type IndexHistoricalsResult struct{} + +// FinancialsRequest is the argument set for get_financials. +type FinancialsRequest struct { + Symbols []string + Period string + Limit int +} + +// FinancialsResult is the parsed get_financials payload. +type FinancialsResult struct{} + +// EarningsResultsRequest is the argument set for get_earnings_results. +type EarningsResultsRequest struct { + Symbol string +} + +// EarningsResultsResult is the parsed get_earnings_results payload. +type EarningsResultsResult struct { + NextReportDate string `json:"next_report_date"` + ReportDate string `json:"report_date"` +} + +// EarningsCalendarRequest is the argument set for get_earnings_calendar. +type EarningsCalendarRequest struct { + StartDate string + Days int + Filter string +} + +// EarningsCalendarResult is the parsed get_earnings_calendar payload. +type EarningsCalendarResult struct{} + +// SECFilingIndexRequest is the argument set for get_sec_filing_index. +type SECFilingIndexRequest struct { + Symbol string + FormType []string + Since string + Until string + Cursor string +} + +// SECFilingIndexResult is the parsed get_sec_filing_index payload. +type SECFilingIndexResult struct{} + +// SECFilingRequest is the argument set for get_sec_filing. +type SECFilingRequest struct { + FilingID string + Section string +} + +// SECFilingResult is the parsed get_sec_filing payload. +type SECFilingResult struct{} + +// SECFilingFactsRequest is the argument set for get_sec_filing_facts. +type SECFilingFactsRequest struct { + FilingIDs []string + Concepts []string +} + +// SECFilingFactsResult is the parsed get_sec_filing_facts payload. +type SECFilingFactsResult struct{} + +// SECFilingFactsCatalogRequest is the argument set for get_sec_filing_facts_catalog. +type SECFilingFactsCatalogRequest struct { + FilingID string + ConceptContains string + AxisNameIn []string + Offset int +} + +// SECFilingFactsCatalogResult is the parsed get_sec_filing_facts_catalog payload. +type SECFilingFactsCatalogResult struct{} + +// Indexes calls get_indexes. +func (c *Client) Indexes(ctx context.Context, req IndexesRequest) (IndexesResult, error) { + args := map[string]any{} + if req.Symbols != "" { + args["symbols"] = req.Symbols + } + var out IndexesResult + if err := c.parse(ctx, toolIndexes, args, &out); err != nil { + return IndexesResult{}, err + } + return out, nil +} + +// IndexQuotes calls get_index_quotes. +func (c *Client) IndexQuotes(ctx context.Context, req IndexQuotesRequest) (IndexQuotesResult, error) { + args := map[string]any{} + if len(req.InstrumentIDs) > 0 { + args["instrument_ids"] = req.InstrumentIDs + } + var out IndexQuotesResult + if err := c.parse(ctx, toolIndexQuotes, args, &out); err != nil { + return IndexQuotesResult{}, err + } + return out, nil +} + +// IndexHistoricals calls get_index_historicals. start_time and interval are always sent (required; no hidden default). +func (c *Client) IndexHistoricals(ctx context.Context, req IndexHistoricalsRequest) (IndexHistoricalsResult, error) { + args := map[string]any{ + "start_time": req.StartTime.UTC().Format(time.RFC3339), + "interval": req.Interval, + } + if len(req.InstrumentIDs) > 0 { + args["instrument_ids"] = req.InstrumentIDs + } + if !req.EndTime.IsZero() { + args["end_time"] = req.EndTime.UTC().Format(time.RFC3339) + } + var out IndexHistoricalsResult + if err := c.parse(ctx, toolIndexHistoricals, args, &out); err != nil { + return IndexHistoricalsResult{}, err + } + return out, nil +} + +// Financials calls get_financials. +func (c *Client) Financials(ctx context.Context, req FinancialsRequest) (FinancialsResult, error) { + args := map[string]any{} + if len(req.Symbols) > 0 { + args["symbols"] = req.Symbols + } + if req.Period != "" { + args["period"] = req.Period + } + if req.Limit != 0 { + args["limit"] = req.Limit + } + var out FinancialsResult + if err := c.parse(ctx, toolFinancials, args, &out); err != nil { + return FinancialsResult{}, err + } + return out, nil +} + +// EarningsResults calls get_earnings_results. +func (c *Client) EarningsResults(ctx context.Context, req EarningsResultsRequest) (EarningsResultsResult, error) { + args := map[string]any{} + if req.Symbol != "" { + args["symbol"] = req.Symbol + } + var out EarningsResultsResult + if err := c.parse(ctx, toolEarningsResults, args, &out); err != nil { + return EarningsResultsResult{}, err + } + return out, nil +} + +// EarningsCalendar calls get_earnings_calendar. +func (c *Client) EarningsCalendar(ctx context.Context, req EarningsCalendarRequest) (EarningsCalendarResult, error) { + args := map[string]any{} + if req.StartDate != "" { + args["start_date"] = req.StartDate + } + if req.Days != 0 { + args["days"] = req.Days + } + if req.Filter != "" { + args["filter"] = req.Filter + } + var out EarningsCalendarResult + if err := c.parse(ctx, toolEarningsCalendar, args, &out); err != nil { + return EarningsCalendarResult{}, err + } + return out, nil +} + +// SECFilingIndex calls get_sec_filing_index. +func (c *Client) SECFilingIndex(ctx context.Context, req SECFilingIndexRequest) (SECFilingIndexResult, error) { + args := map[string]any{} + if req.Symbol != "" { + args["symbol"] = req.Symbol + } + if len(req.FormType) > 0 { + args["form_type"] = req.FormType + } + if req.Since != "" { + args["since"] = req.Since + } + if req.Until != "" { + args["until"] = req.Until + } + if req.Cursor != "" { + args["cursor"] = req.Cursor + } + var out SECFilingIndexResult + if err := c.parse(ctx, toolSECFilingIndex, args, &out); err != nil { + return SECFilingIndexResult{}, err + } + return out, nil +} + +// SECFiling calls get_sec_filing. +func (c *Client) SECFiling(ctx context.Context, req SECFilingRequest) (SECFilingResult, error) { + args := map[string]any{} + if req.FilingID != "" { + args["filing_id"] = req.FilingID + } + if req.Section != "" { + args["section"] = req.Section + } + var out SECFilingResult + if err := c.parse(ctx, toolSECFiling, args, &out); err != nil { + return SECFilingResult{}, err + } + return out, nil +} + +// SECFilingFacts calls get_sec_filing_facts. +func (c *Client) SECFilingFacts(ctx context.Context, req SECFilingFactsRequest) (SECFilingFactsResult, error) { + args := map[string]any{} + if len(req.FilingIDs) > 0 { + args["filing_ids"] = req.FilingIDs + } + if len(req.Concepts) > 0 { + args["concepts"] = req.Concepts + } + var out SECFilingFactsResult + if err := c.parse(ctx, toolSECFilingFacts, args, &out); err != nil { + return SECFilingFactsResult{}, err + } + return out, nil +} + +// SECFilingFactsCatalog calls get_sec_filing_facts_catalog. +func (c *Client) SECFilingFactsCatalog(ctx context.Context, req SECFilingFactsCatalogRequest) (SECFilingFactsCatalogResult, error) { + args := map[string]any{} + if req.FilingID != "" { + args["filing_id"] = req.FilingID + } + if req.ConceptContains != "" { + args["concept_contains"] = req.ConceptContains + } + if len(req.AxisNameIn) > 0 { + args["axis_name_in"] = req.AxisNameIn + } + if req.Offset != 0 { + args["offset"] = req.Offset + } + var out SECFilingFactsCatalogResult + if err := c.parse(ctx, toolSECFilingFactsCatalog, args, &out); err != nil { + return SECFilingFactsCatalogResult{}, 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/market/market_test.go b/market/market_test.go new file mode 100644 index 0000000..292ca6b --- /dev/null +++ b/market/market_test.go @@ -0,0 +1,243 @@ +package market_test + +import ( + "context" + "encoding/json" + "sort" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest" + "s1d3sw1ped/robinhood-agentic-mcp/market" +) + +func TestMarket_toolNames(t *testing.T) { + t.Parallel() + start := time.Date(2026, 8, 18, 13, 30, 0, 0, time.UTC) + end := time.Date(2026, 8, 18, 20, 0, 0, 0, time.UTC) + tests := []struct { + name string + call func(*market.Client) error + wantName string + wantArgs map[string]any + }{ + { + name: "Indexes", + call: func(c *market.Client) error { + _, err := c.Indexes(context.Background(), market.IndexesRequest{Symbols: "SPX,NDX"}) + return err + }, + wantName: "get_indexes", + wantArgs: map[string]any{"symbols": "SPX,NDX"}, + }, + { + name: "IndexQuotes", + call: func(c *market.Client) error { + _, err := c.IndexQuotes(context.Background(), market.IndexQuotesRequest{ + InstrumentIDs: []string{"idx-spx", "idx-ndx"}, + }) + return err + }, + wantName: "get_index_quotes", + wantArgs: map[string]any{"instrument_ids": []string{"idx-spx", "idx-ndx"}}, + }, + { + name: "IndexHistoricals", + call: func(c *market.Client) error { + _, err := c.IndexHistoricals(context.Background(), market.IndexHistoricalsRequest{ + InstrumentIDs: []string{"idx-spx"}, + StartTime: start, + EndTime: end, + Interval: "day", + }) + return err + }, + wantName: "get_index_historicals", + wantArgs: map[string]any{ + "instrument_ids": []string{"idx-spx"}, + "start_time": "2026-08-18T13:30:00Z", + "end_time": "2026-08-18T20:00:00Z", + "interval": "day", + }, + }, + { + name: "Financials", + call: func(c *market.Client) error { + _, err := c.Financials(context.Background(), market.FinancialsRequest{ + Symbols: []string{"MU", "AAPL"}, + Period: "quarterly", + Limit: 8, + }) + return err + }, + wantName: "get_financials", + wantArgs: map[string]any{ + "symbols": []string{"MU", "AAPL"}, + "period": "quarterly", + "limit": 8, + }, + }, + { + name: "EarningsResults", + call: func(c *market.Client) error { + _, err := c.EarningsResults(context.Background(), market.EarningsResultsRequest{Symbol: "MU"}) + return err + }, + wantName: "get_earnings_results", + wantArgs: map[string]any{"symbol": "MU"}, + }, + { + name: "EarningsCalendar", + call: func(c *market.Client) error { + _, err := c.EarningsCalendar(context.Background(), market.EarningsCalendarRequest{ + StartDate: "2026-08-18", + Days: 7, + Filter: "high_market_cap", + }) + return err + }, + wantName: "get_earnings_calendar", + wantArgs: map[string]any{ + "start_date": "2026-08-18", + "days": 7, + "filter": "high_market_cap", + }, + }, + { + name: "SECFilingIndex", + call: func(c *market.Client) error { + _, err := c.SECFilingIndex(context.Background(), market.SECFilingIndexRequest{ + Symbol: "MU", + FormType: []string{"10-K", "10-Q"}, + Since: "2026-01-01", + Until: "2026-08-18", + Cursor: "c1", + }) + return err + }, + wantName: "get_sec_filing_index", + wantArgs: map[string]any{ + "symbol": "MU", + "form_type": []string{"10-K", "10-Q"}, + "since": "2026-01-01", + "until": "2026-08-18", + "cursor": "c1", + }, + }, + { + name: "SECFiling", + call: func(c *market.Client) error { + _, err := c.SECFiling(context.Background(), market.SECFilingRequest{ + FilingID: "f-1", + Section: "item1", + }) + return err + }, + wantName: "get_sec_filing", + wantArgs: map[string]any{"filing_id": "f-1", "section": "item1"}, + }, + { + name: "SECFilingFacts", + call: func(c *market.Client) error { + _, err := c.SECFilingFacts(context.Background(), market.SECFilingFactsRequest{ + FilingIDs: []string{"f-1", "f-2"}, + Concepts: []string{"NetIncomeLoss", "Revenues"}, + }) + return err + }, + wantName: "get_sec_filing_facts", + wantArgs: map[string]any{ + "filing_ids": []string{"f-1", "f-2"}, + "concepts": []string{"NetIncomeLoss", "Revenues"}, + }, + }, + { + name: "SECFilingFactsCatalog", + call: func(c *market.Client) error { + _, err := c.SECFilingFactsCatalog(context.Background(), market.SECFilingFactsCatalogRequest{ + FilingID: "f-1", + ConceptContains: "Debt", + AxisNameIn: []string{"LegalEntityAxis"}, + Offset: 10, + }) + return err + }, + wantName: "get_sec_filing_facts_catalog", + wantArgs: map[string]any{ + "filing_id": "f-1", + "concept_contains": "Debt", + "axis_name_in": []string{"LegalEntityAxis"}, + "offset": 10, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var gotName string + var gotArgs map[string]any + c := market.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) + } + if tc.name == "IndexHistoricals" { + if gotArgs["interval"] != "day" { + t.Fatalf("interval %v", gotArgs["interval"]) + } + } + }) + } +} + +func TestTools(t *testing.T) { + t.Parallel() + want := []string{ + "get_earnings_calendar", + "get_earnings_results", + "get_financials", + "get_index_historicals", + "get_index_quotes", + "get_indexes", + "get_sec_filing", + "get_sec_filing_facts", + "get_sec_filing_facts_catalog", + "get_sec_filing_index", + } + got := append([]string(nil), market.Tools()...) + sort.Strings(got) + if diff := cmp.Diff(want, got); diff != "" { + t.Fatal(diff) + } +} + +func TestEarningsResults_rhntest(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.Set("get_earnings_results", json.RawMessage(`{"next_report_date":"2026-10-15","report_date":"2026-07-15"}`)) + c := market.New(&client.Client{URL: s.URL}) + got, err := c.EarningsResults(context.Background(), market.EarningsResultsRequest{Symbol: "MU"}) + if err != nil { + t.Fatal(err) + } + if got.NextReportDate != "2026-10-15" || got.ReportDate != "2026-07-15" { + t.Fatalf("%+v", got) + } + if s.LastName() != "get_earnings_results" { + t.Fatalf("%s", s.LastName()) + } + want := map[string]any{"symbol": "MU"} + if diff := cmp.Diff(want, s.LastArgs()); diff != "" { + t.Fatal(diff) + } +}