fix: parse MCP result payloads and drop place idempotency_key
Typed result structs replace empty envelopes. Equity place sends ref_id only so live additionalProperties:false schemas accept the call.
This commit is contained in:
+93
-9
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
decimal "github.com/alpacahq/alpacadecimal"
|
||||
"s1d3sw1ped/robinhood-agentic-mcp/client"
|
||||
"s1d3sw1ped/robinhood-agentic-mcp/internal/wire"
|
||||
)
|
||||
@@ -27,16 +28,34 @@ type IndexesRequest struct {
|
||||
Symbols string // comma-separated; live schema is a string, not an array
|
||||
}
|
||||
|
||||
// Index is one market index from get_indexes.
|
||||
type Index struct {
|
||||
ID string
|
||||
Symbol string
|
||||
Name string
|
||||
}
|
||||
|
||||
// IndexesResult is the parsed get_indexes payload.
|
||||
type IndexesResult struct{}
|
||||
type IndexesResult struct {
|
||||
Indexes []Index
|
||||
}
|
||||
|
||||
// IndexQuotesRequest is the argument set for get_index_quotes.
|
||||
type IndexQuotesRequest struct {
|
||||
InstrumentIDs []string
|
||||
}
|
||||
|
||||
// IndexQuote is a live index level from get_index_quotes.
|
||||
type IndexQuote struct {
|
||||
InstrumentID string
|
||||
Value decimal.Decimal
|
||||
State string
|
||||
}
|
||||
|
||||
// IndexQuotesResult is the parsed get_index_quotes payload.
|
||||
type IndexQuotesResult struct{}
|
||||
type IndexQuotesResult struct {
|
||||
Quotes []IndexQuote
|
||||
}
|
||||
|
||||
// IndexHistoricalsRequest is the argument set for get_index_historicals.
|
||||
type IndexHistoricalsRequest struct {
|
||||
@@ -46,8 +65,18 @@ type IndexHistoricalsRequest struct {
|
||||
Interval string // required — no hidden default
|
||||
}
|
||||
|
||||
// IndexBar is one OHLC bar from get_index_historicals.
|
||||
type IndexBar struct {
|
||||
InstrumentID string
|
||||
Time time.Time
|
||||
Open, High, Low, Close decimal.Decimal
|
||||
Interpolated bool
|
||||
}
|
||||
|
||||
// IndexHistoricalsResult is the parsed get_index_historicals payload.
|
||||
type IndexHistoricalsResult struct{}
|
||||
type IndexHistoricalsResult struct {
|
||||
Bars []IndexBar
|
||||
}
|
||||
|
||||
// FinancialsRequest is the argument set for get_financials.
|
||||
type FinancialsRequest struct {
|
||||
@@ -56,8 +85,20 @@ type FinancialsRequest struct {
|
||||
Limit int
|
||||
}
|
||||
|
||||
// FinancialPeriod is one fiscal period from get_financials.
|
||||
type FinancialPeriod struct {
|
||||
Symbol string
|
||||
Period string
|
||||
Revenue decimal.Decimal
|
||||
GrossProfit decimal.Decimal
|
||||
NetIncome decimal.Decimal
|
||||
NetMargin decimal.Decimal
|
||||
}
|
||||
|
||||
// FinancialsResult is the parsed get_financials payload.
|
||||
type FinancialsResult struct{}
|
||||
type FinancialsResult struct {
|
||||
Periods []FinancialPeriod
|
||||
}
|
||||
|
||||
// EarningsResultsRequest is the argument set for get_earnings_results.
|
||||
type EarningsResultsRequest struct {
|
||||
@@ -77,8 +118,16 @@ type EarningsCalendarRequest struct {
|
||||
Filter string
|
||||
}
|
||||
|
||||
// EarningsEvent is one calendar row from get_earnings_calendar.
|
||||
type EarningsEvent struct {
|
||||
Symbol string
|
||||
ReportDate string
|
||||
}
|
||||
|
||||
// EarningsCalendarResult is the parsed get_earnings_calendar payload.
|
||||
type EarningsCalendarResult struct{}
|
||||
type EarningsCalendarResult struct {
|
||||
Events []EarningsEvent
|
||||
}
|
||||
|
||||
// SECFilingIndexRequest is the argument set for get_sec_filing_index.
|
||||
type SECFilingIndexRequest struct {
|
||||
@@ -89,8 +138,18 @@ type SECFilingIndexRequest struct {
|
||||
Cursor string
|
||||
}
|
||||
|
||||
// SECFilingRef is one filing from get_sec_filing_index.
|
||||
type SECFilingRef struct {
|
||||
FilingID string
|
||||
FormType string
|
||||
FiledAt string
|
||||
}
|
||||
|
||||
// SECFilingIndexResult is the parsed get_sec_filing_index payload.
|
||||
type SECFilingIndexResult struct{}
|
||||
type SECFilingIndexResult struct {
|
||||
Filings []SECFilingRef
|
||||
NextCursor string
|
||||
}
|
||||
|
||||
// SECFilingRequest is the argument set for get_sec_filing.
|
||||
type SECFilingRequest struct {
|
||||
@@ -98,8 +157,19 @@ type SECFilingRequest struct {
|
||||
Section string
|
||||
}
|
||||
|
||||
// SECSection is one table-of-contents row or section body.
|
||||
type SECSection struct {
|
||||
ID string
|
||||
Title string
|
||||
Text string
|
||||
}
|
||||
|
||||
// SECFilingResult is the parsed get_sec_filing payload.
|
||||
type SECFilingResult struct{}
|
||||
type SECFilingResult struct {
|
||||
FilingID string
|
||||
Sections []SECSection
|
||||
Text string
|
||||
}
|
||||
|
||||
// SECFilingFactsRequest is the argument set for get_sec_filing_facts.
|
||||
type SECFilingFactsRequest struct {
|
||||
@@ -108,7 +178,15 @@ type SECFilingFactsRequest struct {
|
||||
}
|
||||
|
||||
// SECFilingFactsResult is the parsed get_sec_filing_facts payload.
|
||||
type SECFilingFactsResult struct{}
|
||||
type SECFact struct {
|
||||
Concept string
|
||||
Value string
|
||||
Unit string
|
||||
}
|
||||
|
||||
type SECFilingFactsResult struct {
|
||||
Facts []SECFact
|
||||
}
|
||||
|
||||
// SECFilingFactsCatalogRequest is the argument set for get_sec_filing_facts_catalog.
|
||||
type SECFilingFactsCatalogRequest struct {
|
||||
@@ -119,7 +197,13 @@ type SECFilingFactsCatalogRequest struct {
|
||||
}
|
||||
|
||||
// SECFilingFactsCatalogResult is the parsed get_sec_filing_facts_catalog payload.
|
||||
type SECFilingFactsCatalogResult struct{}
|
||||
type SECConcept struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type SECFilingFactsCatalogResult struct {
|
||||
Concepts []SECConcept
|
||||
}
|
||||
|
||||
// Indexes calls get_indexes.
|
||||
func (c *Client) Indexes(ctx context.Context, req IndexesRequest) (IndexesResult, error) {
|
||||
|
||||
@@ -241,3 +241,17 @@ func TestEarningsResults_rhntest(t *testing.T) {
|
||||
t.Fatal(diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEarningsResults_liveEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := market.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"data":{"results":[{"report":{"date":"2026-07-15"}}]}}`), nil
|
||||
}))
|
||||
got, err := c.EarningsResults(context.Background(), market.EarningsResultsRequest{Symbol: "MU"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ReportDate != "2026-07-15" || got.NextReportDate != "2026-07-15" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package market
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
decimal "github.com/alpacahq/alpacadecimal"
|
||||
"s1d3sw1ped/robinhood-agentic-mcp/internal/wire"
|
||||
)
|
||||
|
||||
func firstDec(vs ...any) (decimal.Decimal, error) {
|
||||
for _, v := range vs {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if s, ok := v.(string); ok && s == "" {
|
||||
continue
|
||||
}
|
||||
return wire.Dec(v)
|
||||
}
|
||||
return decimal.Zero, nil
|
||||
}
|
||||
|
||||
func (r *IndexesResult) UnmarshalJSON(b []byte) error {
|
||||
type row struct {
|
||||
ID string `json:"id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
rows, _, err := wire.UnmarshalRows[row](b, "indexes", "results")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Indexes = make([]Index, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
r.Indexes = append(r.Indexes, Index{ID: row.ID, Symbol: row.Symbol, Name: row.Name})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *IndexQuotesResult) UnmarshalJSON(b []byte) error {
|
||||
type row struct {
|
||||
InstrumentID string `json:"instrument_id"`
|
||||
ID string `json:"id"`
|
||||
Value any `json:"value"`
|
||||
Last any `json:"last"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
rows, _, err := wire.UnmarshalRows[row](b, "quotes", "results")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Quotes = make([]IndexQuote, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
id := row.InstrumentID
|
||||
if id == "" {
|
||||
id = row.ID
|
||||
}
|
||||
val, err := firstDec(row.Value, row.Last)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Quotes = append(r.Quotes, IndexQuote{InstrumentID: id, Value: val, State: row.State})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *IndexHistoricalsResult) UnmarshalJSON(b []byte) error {
|
||||
type pt struct {
|
||||
BeginsAt string `json:"begins_at"`
|
||||
Open any `json:"open"`
|
||||
High any `json:"high"`
|
||||
Low any `json:"low"`
|
||||
Close any `json:"close"`
|
||||
Interpolated bool `json:"interpolated"`
|
||||
}
|
||||
type series struct {
|
||||
InstrumentID string `json:"instrument_id"`
|
||||
ID string `json:"id"`
|
||||
DataPoints []pt `json:"data_points"`
|
||||
}
|
||||
rows, _, err := wire.UnmarshalRows[series](b, "historicals", "results")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range rows {
|
||||
id := s.InstrumentID
|
||||
if id == "" {
|
||||
id = s.ID
|
||||
}
|
||||
for _, p := range s.DataPoints {
|
||||
ts, err := time.Parse(time.RFC3339, p.BeginsAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
o, err := firstDec(p.Open)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, err := firstDec(p.High)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l, err := firstDec(p.Low)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cl, err := firstDec(p.Close)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Bars = append(r.Bars, IndexBar{InstrumentID: id, Time: ts, Open: o, High: h, Low: l, Close: cl, Interpolated: p.Interpolated})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FinancialsResult) UnmarshalJSON(b []byte) error {
|
||||
type row struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Period string `json:"period"`
|
||||
Revenue any `json:"revenue"`
|
||||
GrossProfit any `json:"gross_profit"`
|
||||
NetIncome any `json:"net_income"`
|
||||
NetMargin any `json:"net_margin"`
|
||||
}
|
||||
rows, _, err := wire.UnmarshalRows[row](b, "financials", "results")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Periods = make([]FinancialPeriod, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
rev, err := firstDec(row.Revenue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gp, err := firstDec(row.GrossProfit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ni, err := firstDec(row.NetIncome)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nm, err := firstDec(row.NetMargin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Periods = append(r.Periods, FinancialPeriod{
|
||||
Symbol: row.Symbol, Period: row.Period, Revenue: rev, GrossProfit: gp, NetIncome: ni, NetMargin: nm,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *EarningsResultsResult) UnmarshalJSON(b []byte) error {
|
||||
var wrap struct {
|
||||
NextReportDate string `json:"next_report_date"`
|
||||
ReportDate string `json:"report_date"`
|
||||
Results []struct {
|
||||
Report struct {
|
||||
Date string `json:"date"`
|
||||
} `json:"report"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(wire.Unwrap(b), &wrap); err != nil {
|
||||
return err
|
||||
}
|
||||
r.NextReportDate = wrap.NextReportDate
|
||||
r.ReportDate = wrap.ReportDate
|
||||
if r.ReportDate == "" && len(wrap.Results) > 0 {
|
||||
r.ReportDate = wrap.Results[0].Report.Date
|
||||
}
|
||||
if r.NextReportDate == "" {
|
||||
r.NextReportDate = r.ReportDate
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *EarningsCalendarResult) UnmarshalJSON(b []byte) error {
|
||||
type row struct {
|
||||
Symbol string `json:"symbol"`
|
||||
ReportDate string `json:"report_date"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
rows, _, err := wire.UnmarshalRows[row](b, "results", "earnings")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Events = make([]EarningsEvent, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
d := row.ReportDate
|
||||
if d == "" {
|
||||
d = row.Date
|
||||
}
|
||||
r.Events = append(r.Events, EarningsEvent{Symbol: row.Symbol, ReportDate: d})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SECFilingIndexResult) UnmarshalJSON(b []byte) error {
|
||||
type row struct {
|
||||
FilingID string `json:"filing_id"`
|
||||
ID string `json:"id"`
|
||||
FormType string `json:"form_type"`
|
||||
FiledAt string `json:"filed_at"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
rows, next, err := wire.UnmarshalRows[row](b, "filings", "results")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.NextCursor = next
|
||||
r.Filings = make([]SECFilingRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
id := row.FilingID
|
||||
if id == "" {
|
||||
id = row.ID
|
||||
}
|
||||
when := row.FiledAt
|
||||
if when == "" {
|
||||
when = row.Date
|
||||
}
|
||||
r.Filings = append(r.Filings, SECFilingRef{FilingID: id, FormType: row.FormType, FiledAt: when})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SECFilingResult) UnmarshalJSON(b []byte) error {
|
||||
var wrap struct {
|
||||
FilingID string `json:"filing_id"`
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Sections []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
} `json:"sections"`
|
||||
TOC []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
} `json:"table_of_contents"`
|
||||
}
|
||||
if err := json.Unmarshal(wire.Unwrap(b), &wrap); err != nil {
|
||||
return err
|
||||
}
|
||||
r.FilingID = wrap.FilingID
|
||||
if r.FilingID == "" {
|
||||
r.FilingID = wrap.ID
|
||||
}
|
||||
r.Text = wrap.Text
|
||||
for _, s := range wrap.Sections {
|
||||
r.Sections = append(r.Sections, SECSection{ID: s.ID, Title: s.Title, Text: s.Text})
|
||||
}
|
||||
if len(r.Sections) == 0 {
|
||||
for _, s := range wrap.TOC {
|
||||
r.Sections = append(r.Sections, SECSection{ID: s.ID, Title: s.Title})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SECFilingFactsResult) UnmarshalJSON(b []byte) error {
|
||||
type row struct {
|
||||
Concept string `json:"concept"`
|
||||
Value string `json:"value"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
rows, _, err := wire.UnmarshalRows[row](b, "facts", "results")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Facts = make([]SECFact, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
r.Facts = append(r.Facts, SECFact{Concept: row.Concept, Value: row.Value, Unit: row.Unit})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SECFilingFactsCatalogResult) UnmarshalJSON(b []byte) error {
|
||||
type row struct {
|
||||
Name string `json:"name"`
|
||||
Concept string `json:"concept"`
|
||||
}
|
||||
rows, _, err := wire.UnmarshalRows[row](b, "concepts", "results")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Concepts = make([]SECConcept, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
n := row.Name
|
||||
if n == "" {
|
||||
n = row.Concept
|
||||
}
|
||||
r.Concepts = append(r.Concepts, SECConcept{Name: n})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user