From a6bf8632ce2893ab16aec33e69d107f5e87295d9 Mon Sep 17 00:00:00 2001 From: Justin Harms Date: Tue, 1 Sep 2026 14:18:39 -0500 Subject: [PATCH] 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. --- accounts/accounts.go | 51 +++++- accounts/accounts_test.go | 14 ++ accounts/results.go | 190 +++++++++++++++++++++ crypto/crypto.go | 69 ++++++-- crypto/results.go | 174 ++++++++++++++++++++ equity/read.go | 103 +++++++++++- equity/results.go | 328 +++++++++++++++++++++++++++++++++++++ equity/results_test.go | 70 ++++++++ equity/write.go | 88 +++++++++- equity/write_test.go | 43 +++-- internal/wire/list.go | 76 +++++++++ internal/wire/list_test.go | 49 ++++++ market/market.go | 102 +++++++++++- market/market_test.go | 14 ++ market/results.go | 298 +++++++++++++++++++++++++++++++++ options/read.go | 76 ++++++++- options/results.go | 275 +++++++++++++++++++++++++++++++ options/write.go | 44 +++-- scanner/results.go | 111 +++++++++++++ scanner/scanner.go | 53 +++++- watchlists/results.go | 55 +++++++ watchlists/watchlists.go | 16 +- 22 files changed, 2225 insertions(+), 74 deletions(-) create mode 100644 accounts/results.go create mode 100644 crypto/results.go create mode 100644 equity/results.go create mode 100644 equity/results_test.go create mode 100644 internal/wire/list.go create mode 100644 internal/wire/list_test.go create mode 100644 market/results.go create mode 100644 options/results.go create mode 100644 scanner/results.go create mode 100644 watchlists/results.go diff --git a/accounts/accounts.go b/accounts/accounts.go index fc82930..1998f9a 100644 --- a/accounts/accounts.go +++ b/accounts/accounts.go @@ -60,8 +60,20 @@ type RealizedPnLRequest struct { Timezone string } +// PnLBucket is one span bucket from get_realized_pnl. +type PnLBucket struct { + Label string + Amount decimal.Decimal + Percent decimal.Decimal + Trades int +} + // RealizedPnLResult is the parsed get_realized_pnl payload. -type RealizedPnLResult struct{} +type RealizedPnLResult struct { + Total decimal.Decimal + Percent decimal.Decimal + Buckets []PnLBucket +} // PnLTradeHistoryRequest is the argument set for get_pnl_trade_history. type PnLTradeHistoryRequest struct { @@ -71,8 +83,20 @@ type PnLTradeHistoryRequest struct { Cursor string } +// PnLTrade is one realizing trade from get_pnl_trade_history. +type PnLTrade struct { + Symbol string + Side string + Quantity decimal.Decimal + Price decimal.Decimal + PnL decimal.Decimal +} + // PnLTradeHistoryResult is the parsed get_pnl_trade_history payload. -type PnLTradeHistoryResult struct{} +type PnLTradeHistoryResult struct { + Trades []PnLTrade + NextCursor string +} // AccountNumberRequest is a single account_number argument. type AccountNumberRequest struct { @@ -80,10 +104,17 @@ type AccountNumberRequest struct { } // UpgradeInfoResult is the parsed upgrade-info payload. -type UpgradeInfoResult struct{} +type UpgradeInfoResult struct { + URL string + WebURL string + MobileURL string +} // OnboardingInfoResult is the parsed crypto onboarding payload. -type OnboardingInfoResult struct{} +type OnboardingInfoResult struct { + URL string + WebURL string +} // SearchRequest is the argument set for search. type SearchRequest struct { @@ -92,8 +123,18 @@ type SearchRequest struct { Limit int } +// SearchHit is one instrument/pair/index from search. +type SearchHit struct { + Symbol string + Name string + ID string + InstrumentID string +} + // SearchResult is the parsed search payload. -type SearchResult struct{} +type SearchResult struct { + Results []SearchHit +} // Accounts calls get_accounts and returns every account (no IRA/margin filter). func (c *Client) Accounts(ctx context.Context, req AccountsRequest) (AccountsResult, error) { diff --git a/accounts/accounts_test.go b/accounts/accounts_test.go index cb068d1..d8ecf73 100644 --- a/accounts/accounts_test.go +++ b/accounts/accounts_test.go @@ -208,3 +208,17 @@ func TestPortfolio_rhntest(t *testing.T) { t.Fatalf("buying power %s", got.BuyingPower) } } + +func TestSearch_parsesResults(t *testing.T) { + t.Parallel() + c := accounts.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + return json.RawMessage(`{"results":[{"symbol":"AAPL","name":"Apple","instrument_id":"i1"}]}`), nil + })) + got, err := c.Search(context.Background(), accounts.SearchRequest{Query: "apple"}) + if err != nil { + t.Fatal(err) + } + if len(got.Results) != 1 || got.Results[0].Symbol != "AAPL" || got.Results[0].InstrumentID != "i1" { + t.Fatalf("%+v", got) + } +} diff --git a/accounts/results.go b/accounts/results.go new file mode 100644 index 0000000..82454e2 --- /dev/null +++ b/accounts/results.go @@ -0,0 +1,190 @@ +package accounts + +import ( + "encoding/json" + + decimal "github.com/alpacahq/alpacadecimal" + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +func (r *RealizedPnLResult) UnmarshalJSON(b []byte) error { + var wrap struct { + Total any `json:"total"` + TotalGain any `json:"total_gain"` + Percent any `json:"percent"` + TotalPct any `json:"total_percent"` + Buckets []struct { + Label string `json:"label"` + Name string `json:"name"` + Amount any `json:"amount"` + Gain any `json:"gain"` + Percent any `json:"percent"` + Trades int `json:"trades"` + Count int `json:"count"` + } `json:"buckets"` + Results []struct { + Label string `json:"label"` + Amount any `json:"amount"` + Percent any `json:"percent"` + Trades int `json:"trades"` + } `json:"results"` + } + if err := json.Unmarshal(wire.Unwrap(b), &wrap); err != nil { + return err + } + var err error + if r.Total, err = firstDec(wrap.Total, wrap.TotalGain); err != nil { + return err + } + if r.Percent, err = firstDec(wrap.Percent, wrap.TotalPct); err != nil { + return err + } + src := wrap.Buckets + if len(src) == 0 { + for _, row := range wrap.Results { + src = append(src, struct { + Label string `json:"label"` + Name string `json:"name"` + Amount any `json:"amount"` + Gain any `json:"gain"` + Percent any `json:"percent"` + Trades int `json:"trades"` + Count int `json:"count"` + }{Label: row.Label, Amount: row.Amount, Percent: row.Percent, Trades: row.Trades}) + } + } + r.Buckets = make([]PnLBucket, 0, len(src)) + for _, row := range src { + label := row.Label + if label == "" { + label = row.Name + } + amt, err := firstDec(row.Amount, row.Gain) + if err != nil { + return err + } + pct, err := firstDec(row.Percent) + if err != nil { + return err + } + n := row.Trades + if n == 0 { + n = row.Count + } + r.Buckets = append(r.Buckets, PnLBucket{Label: label, Amount: amt, Percent: pct, Trades: n}) + } + return nil +} + +func (r *PnLTradeHistoryResult) UnmarshalJSON(b []byte) error { + type row struct { + Symbol string `json:"symbol"` + Side string `json:"side"` + Quantity any `json:"quantity"` + Price any `json:"price"` + PnL any `json:"realized_gain"` + Gain any `json:"gain"` + } + rows, next, err := wire.UnmarshalRows[row](b, "trades", "results") + if err != nil { + return err + } + r.NextCursor = next + r.Trades = make([]PnLTrade, 0, len(rows)) + for _, row := range rows { + qty, err := firstDec(row.Quantity) + if err != nil { + return err + } + px, err := firstDec(row.Price) + if err != nil { + return err + } + pnl, err := firstDec(row.PnL, row.Gain) + if err != nil { + return err + } + r.Trades = append(r.Trades, PnLTrade{Symbol: row.Symbol, Side: row.Side, Quantity: qty, Price: px, PnL: pnl}) + } + return nil +} + +func (r *UpgradeInfoResult) UnmarshalJSON(b []byte) error { + var wrap struct { + URL string `json:"url"` + WebURL string `json:"web_url"` + MobileURL string `json:"mobile_url"` + Links struct { + Web string `json:"web"` + Mobile string `json:"mobile"` + } `json:"links"` + } + if err := json.Unmarshal(wire.Unwrap(b), &wrap); err != nil { + return err + } + r.URL = wrap.URL + r.WebURL = wrap.WebURL + if r.WebURL == "" { + r.WebURL = wrap.Links.Web + } + r.MobileURL = wrap.MobileURL + if r.MobileURL == "" { + r.MobileURL = wrap.Links.Mobile + } + if r.URL == "" { + r.URL = r.WebURL + } + return nil +} + +func (r *OnboardingInfoResult) UnmarshalJSON(b []byte) error { + var wrap struct { + URL string `json:"url"` + WebURL string `json:"web_url"` + } + if err := json.Unmarshal(wire.Unwrap(b), &wrap); err != nil { + return err + } + r.URL = wrap.URL + r.WebURL = wrap.WebURL + if r.URL == "" { + r.URL = r.WebURL + } + return nil +} + +func (r *SearchResult) UnmarshalJSON(b []byte) error { + type row struct { + Symbol string `json:"symbol"` + Name string `json:"name"` + ID string `json:"id"` + InstrumentID string `json:"instrument_id"` + } + rows, _, err := wire.UnmarshalRows[row](b, "results", "instruments") + if err != nil { + return err + } + r.Results = make([]SearchHit, 0, len(rows)) + for _, row := range rows { + r.Results = append(r.Results, SearchHit{ + Symbol: row.Symbol, + Name: row.Name, + ID: row.ID, + InstrumentID: row.InstrumentID, + }) + } + return nil +} + +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 +} diff --git a/crypto/crypto.go b/crypto/crypto.go index 69c2eaa..8b41591 100644 --- a/crypto/crypto.go +++ b/crypto/crypto.go @@ -25,8 +25,17 @@ type PairsRequest struct { Limit int } +// CurrencyPair is one row from get_currency_pairs. +type CurrencyPair struct { + ID string + Symbol string +} + // PairsResult is the parsed get_currency_pairs payload. -type PairsResult struct{} +type PairsResult struct { + Pairs []CurrencyPair + NextCursor string +} // QuotesRequest is the argument set for get_crypto_quotes. type QuotesRequest struct { @@ -35,8 +44,19 @@ type QuotesRequest struct { RHSAccountNumber string } +// CryptoQuote is one pair quote from get_crypto_quotes. +type CryptoQuote struct { + Symbol string + Bid decimal.Decimal + Ask decimal.Decimal + Mark decimal.Decimal + PrevClose decimal.Decimal +} + // QuotesResult is the parsed get_crypto_quotes payload. -type QuotesResult struct{} +type QuotesResult struct { + Quotes []CryptoQuote +} // PositionsRequest is the argument set for get_crypto_positions. type PositionsRequest struct { @@ -44,8 +64,18 @@ type PositionsRequest struct { Cursor string } +// CryptoPosition is one holding from get_crypto_positions. +type CryptoPosition struct { + Symbol string + Quantity decimal.Decimal + CostBasis decimal.Decimal +} + // PositionsResult is the parsed get_crypto_positions payload. -type PositionsResult struct{} +type PositionsResult struct { + Positions []CryptoPosition + NextCursor string +} // OrdersRequest is the argument set for get_crypto_orders. type OrdersRequest struct { @@ -61,7 +91,10 @@ type OrdersRequest struct { } // OrdersResult is the parsed get_crypto_orders payload. -type OrdersResult struct{} +type OrdersResult struct { + Orders []Order + NextCursor string +} // PlaceOrderRequest is the argument set for preview_crypto_order and place_crypto_order. type PlaceOrderRequest struct { @@ -78,11 +111,20 @@ type PlaceOrderRequest struct { } // PreviewResult is the pre-trade check from preview_crypto_order. -type PreviewResult struct{} +type PreviewResult struct { + Errors []string `json:"errors"` + Warnings []string `json:"warnings"` + Quantity decimal.Decimal `json:"-"` + Price decimal.Decimal `json:"-"` +} -// Order is a placed crypto order. +// Order is a placed or listed crypto order. type Order struct { - ID string `json:"id"` + ID string `json:"id"` + Symbol string `json:"symbol"` + Side string `json:"side"` + State string `json:"state"` + Qty decimal.Decimal `json:"-"` } // CancelOrderRequest is the argument set for cancel_crypto_order. @@ -190,11 +232,18 @@ func (c *Client) PreviewOrder(ctx context.Context, req PlaceOrderRequest) (Previ // PlaceOrder calls place_crypto_order. RefID is sent as ref_id. func (c *Client) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (Order, error) { - var out Order - if err := c.parse(ctx, toolPlace, placeArgs(req, true), &out); err != nil { + raw, err := c.c.Call(ctx, toolPlace, placeArgs(req, true)) + if err != nil { return Order{}, err } - return out, nil + ords, err := parseOrders(raw) + if err != nil { + return Order{}, client.ToolErrorf(toolPlace, "parse: %w", err) + } + if len(ords) > 0 { + return ords[0], nil + } + return Order{}, nil } // CancelOrder calls cancel_crypto_order. diff --git a/crypto/results.go b/crypto/results.go new file mode 100644 index 0000000..c67a102 --- /dev/null +++ b/crypto/results.go @@ -0,0 +1,174 @@ +package crypto + +import ( + "encoding/json" + + decimal "github.com/alpacahq/alpacadecimal" + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +func (o *Order) UnmarshalJSON(b []byte) error { + var row struct { + ID string `json:"id"` + Symbol string `json:"symbol"` + Side string `json:"side"` + State string `json:"state"` + Status string `json:"status"` + Quantity any `json:"quantity"` + } + if err := json.Unmarshal(b, &row); err != nil { + return err + } + o.ID, o.Symbol, o.Side = row.ID, row.Symbol, row.Side + o.State = row.State + if o.State == "" { + o.State = row.Status + } + var err error + o.Qty, err = firstDec(row.Quantity) + return err +} + +func parseOrders(raw json.RawMessage) ([]Order, error) { + rows, _, err := wire.UnmarshalRows[Order](raw, "results", "orders") + if err != nil { + return nil, err + } + if len(rows) > 0 { + return rows, nil + } + var one Order + if err := json.Unmarshal(wire.Unwrap(raw), &one); err != nil { + return nil, err + } + if one.ID != "" { + return []Order{one}, nil + } + return nil, nil +} + +func (r *PairsResult) UnmarshalJSON(b []byte) error { + type row struct { + ID string `json:"id"` + Symbol string `json:"symbol"` + } + rows, next, err := wire.UnmarshalRows[row](b, "results", "pairs") + if err != nil { + return err + } + r.NextCursor = next + r.Pairs = make([]CurrencyPair, 0, len(rows)) + for _, row := range rows { + r.Pairs = append(r.Pairs, CurrencyPair{ID: row.ID, Symbol: row.Symbol}) + } + return nil +} + +func (r *QuotesResult) UnmarshalJSON(b []byte) error { + type row struct { + Symbol string `json:"symbol"` + Bid any `json:"bid"` + Ask any `json:"ask"` + Mark any `json:"mark"` + PrevClose any `json:"open_price"` + Close any `json:"previous_close"` + } + rows, _, err := wire.UnmarshalRows[row](b, "results", "quotes") + if err != nil { + return err + } + r.Quotes = make([]CryptoQuote, 0, len(rows)) + for _, row := range rows { + bid, err := firstDec(row.Bid) + if err != nil { + return err + } + ask, err := firstDec(row.Ask) + if err != nil { + return err + } + mark, err := firstDec(row.Mark) + if err != nil { + return err + } + prev, err := firstDec(row.PrevClose, row.Close) + if err != nil { + return err + } + r.Quotes = append(r.Quotes, CryptoQuote{Symbol: row.Symbol, Bid: bid, Ask: ask, Mark: mark, PrevClose: prev}) + } + return nil +} + +func (r *PositionsResult) UnmarshalJSON(b []byte) error { + type row struct { + Symbol string `json:"symbol"` + Asset string `json:"asset"` + Quantity any `json:"quantity"` + CostBasis any `json:"cost_basis"` + } + rows, next, err := wire.UnmarshalRows[row](b, "results", "positions") + if err != nil { + return err + } + r.NextCursor = next + r.Positions = make([]CryptoPosition, 0, len(rows)) + for _, row := range rows { + sym := row.Symbol + if sym == "" { + sym = row.Asset + } + qty, err := firstDec(row.Quantity) + if err != nil { + return err + } + cost, err := firstDec(row.CostBasis) + if err != nil { + return err + } + r.Positions = append(r.Positions, CryptoPosition{Symbol: sym, Quantity: qty, CostBasis: cost}) + } + return nil +} + +func (r *OrdersResult) UnmarshalJSON(b []byte) error { + rows, next, err := wire.UnmarshalRows[Order](b, "results", "orders") + if err != nil { + return err + } + r.Orders = rows + r.NextCursor = next + return nil +} + +func (r *PreviewResult) UnmarshalJSON(b []byte) error { + var wrap struct { + Errors []string `json:"errors"` + Warnings []string `json:"warnings"` + Quantity any `json:"quantity"` + Price any `json:"price"` + } + if err := json.Unmarshal(wire.Unwrap(b), &wrap); err != nil { + return err + } + r.Errors, r.Warnings = wrap.Errors, wrap.Warnings + var err error + if r.Quantity, err = firstDec(wrap.Quantity); err != nil { + return err + } + r.Price, err = firstDec(wrap.Price) + return err +} + +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 +} diff --git a/equity/read.go b/equity/read.go index 0a21041..9e11f48 100644 --- a/equity/read.go +++ b/equity/read.go @@ -29,8 +29,18 @@ type PositionsRequest struct { Cursor string } +// Position is one holding from get_equity_positions. +type Position struct { + Symbol string + Qty decimal.Decimal + AvgCost decimal.Decimal +} + // PositionsResult is the parsed get_equity_positions payload. -type PositionsResult struct{} +type PositionsResult struct { + Positions []Position + NextCursor string +} // TaxLotsRequest is the argument set for get_equity_tax_lots. type TaxLotsRequest struct { @@ -39,8 +49,21 @@ type TaxLotsRequest struct { Cursor string } +// TaxLotRow is one open tax lot from get_equity_tax_lots. +type TaxLotRow struct { + OpenLotID string + Quantity decimal.Decimal + CostBasis decimal.Decimal + AcquiredAt string + Term string + QuantityAvail decimal.Decimal +} + // TaxLotsResult is the parsed get_equity_tax_lots payload. -type TaxLotsResult struct{} +type TaxLotsResult struct { + Lots []TaxLotRow + NextCursor string +} // QuotesRequest is the argument set for get_equity_quotes. type QuotesRequest struct { @@ -74,7 +97,10 @@ type OrdersRequest struct { } // OrdersResult is the parsed get_equity_orders payload. -type OrdersResult struct{} +type OrdersResult struct { + Orders []Order + NextCursor string +} // TradabilityRequest is the argument set for get_equity_tradability. type TradabilityRequest struct { @@ -82,8 +108,17 @@ type TradabilityRequest struct { Symbols []string } +// SymbolTradability is per-symbol eligibility from get_equity_tradability. +type SymbolTradability struct { + Symbol string + Tradable bool + Fractional bool +} + // TradabilityResult is the parsed get_equity_tradability payload. -type TradabilityResult struct{} +type TradabilityResult struct { + Symbols []SymbolTradability +} // HistoricalsRequest is the argument set for get_equity_historicals. type HistoricalsRequest struct { @@ -115,16 +150,44 @@ type FundamentalsRequest struct { Bounds string } +// Fundamentals is one symbol's facts from get_equity_fundamentals. +type Fundamentals struct { + Symbol string + AvgVolume decimal.Decimal + InstrumentKind string + MarketCap decimal.Decimal + PE decimal.Decimal + High52Week decimal.Decimal + Low52Week decimal.Decimal +} + // FundamentalsResult is the parsed get_equity_fundamentals payload. -type FundamentalsResult struct{} +type FundamentalsResult struct { + Fundamentals []Fundamentals +} // PriceBookRequest is the argument set for get_equity_price_book. type PriceBookRequest struct { Symbols []string } +// BookLevel is one bid or ask rung. +type BookLevel struct { + Price decimal.Decimal + Quantity decimal.Decimal +} + +// PriceBook is a Level-2 snapshot for one symbol. +type PriceBook struct { + Symbol string + Bids []BookLevel + Asks []BookLevel +} + // PriceBookResult is the parsed get_equity_price_book payload. -type PriceBookResult struct{} +type PriceBookResult struct { + Books []PriceBook +} // TechnicalIndicatorsRequest is the argument set for get_equity_technical_indicators. type TechnicalIndicatorsRequest struct { @@ -145,8 +208,20 @@ type TechnicalIndicatorsRequest struct { Method string } +// IndicatorPoint is one computed indicator bar. +type IndicatorPoint struct { + Time time.Time + Value decimal.Decimal + Upper decimal.Decimal + Lower decimal.Decimal + MACD decimal.Decimal + Signal decimal.Decimal +} + // TechnicalIndicatorsResult is the parsed get_equity_technical_indicators payload. -type TechnicalIndicatorsResult struct{} +type TechnicalIndicatorsResult struct { + Points []IndicatorPoint +} // NewsRequest is the argument set for get_equity_news. type NewsRequest struct { @@ -155,8 +230,20 @@ type NewsRequest struct { Cursor string } +// NewsArticle is one article from get_equity_news. +type NewsArticle struct { + Title string + URL string + PublishedAt string + Source string + Summary string +} + // NewsResult is the parsed get_equity_news payload. -type NewsResult struct{} +type NewsResult struct { + Articles []NewsArticle + NextCursor string +} // Positions calls get_equity_positions. func (c *Client) Positions(ctx context.Context, req PositionsRequest) (PositionsResult, error) { diff --git a/equity/results.go b/equity/results.go new file mode 100644 index 0000000..7f11641 --- /dev/null +++ b/equity/results.go @@ -0,0 +1,328 @@ +package equity + +import ( + "encoding/json" + "time" + + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +func (r *PositionsResult) UnmarshalJSON(b []byte) error { + type row struct { + Symbol string `json:"symbol"` + Quantity any `json:"quantity"` + Qty any `json:"qty"` + AverageBuyPrice any `json:"average_buy_price"` + AvgCost any `json:"avg_cost"` + } + rows, next, err := wire.UnmarshalRows[row](b, "positions", "results") + if err != nil { + return err + } + r.NextCursor = next + r.Positions = make([]Position, 0, len(rows)) + for _, row := range rows { + qty, err := firstDec(row.Quantity, row.Qty) + if err != nil { + return err + } + avg, err := firstDec(row.AverageBuyPrice, row.AvgCost) + if err != nil { + return err + } + r.Positions = append(r.Positions, Position{Symbol: row.Symbol, Qty: qty, AvgCost: avg}) + } + return nil +} + +func (r *TaxLotsResult) UnmarshalJSON(b []byte) error { + type row struct { + OpenLotID string `json:"open_lot_id"` + ID string `json:"id"` + Quantity any `json:"quantity"` + QuantityAvailable any `json:"quantity_available"` + CostBasis any `json:"cost_basis"` + AcquiredAt string `json:"acquired_at"` + AcquisitionDate string `json:"acquisition_date"` + Term string `json:"term"` + HoldingPeriod string `json:"holding_period"` + } + rows, next, err := wire.UnmarshalRows[row](b, "tax_lots", "lots", "results") + if err != nil { + return err + } + r.NextCursor = next + r.Lots = make([]TaxLotRow, 0, len(rows)) + for _, row := range rows { + id := row.OpenLotID + if id == "" { + id = row.ID + } + qty, err := firstDec(row.Quantity) + if err != nil { + return err + } + avail, err := firstDec(row.QuantityAvailable) + if err != nil { + return err + } + cost, err := firstDec(row.CostBasis) + if err != nil { + return err + } + when := row.AcquiredAt + if when == "" { + when = row.AcquisitionDate + } + term := row.Term + if term == "" { + term = row.HoldingPeriod + } + r.Lots = append(r.Lots, TaxLotRow{ + OpenLotID: id, + Quantity: qty, + QuantityAvail: avail, + CostBasis: cost, + AcquiredAt: when, + Term: term, + }) + } + return nil +} + +func (r *OrdersResult) UnmarshalJSON(b []byte) error { + rows, next, err := wire.UnmarshalRows[Order](b, "orders", "results") + if err != nil { + return err + } + r.NextCursor = next + if len(rows) == 0 { + var one Order + if err := json.Unmarshal(wire.Unwrap(b), &one); err == nil && one.ID != "" { + rows = []Order{one} + } + } + r.Orders = rows + return nil +} + +func (r *TradabilityResult) UnmarshalJSON(b []byte) error { + type row struct { + Symbol string `json:"symbol"` + Tradable bool `json:"tradable"` + IsTradable bool `json:"is_tradable"` + Fractional bool `json:"fractional"` + FractionalOk bool `json:"fractional_tradable"` + FractionalTrad bool `json:"fractionally_tradable"` + } + rows, _, err := wire.UnmarshalRows[row](b, "results", "tradability", "instruments") + if err != nil { + return err + } + if len(rows) == 0 { + var one row + if json.Unmarshal(wire.Unwrap(b), &one) == nil && (one.Symbol != "" || one.Tradable || one.IsTradable || one.Fractional) { + rows = []row{one} + } + } + r.Symbols = make([]SymbolTradability, 0, len(rows)) + for _, row := range rows { + r.Symbols = append(r.Symbols, SymbolTradability{ + Symbol: row.Symbol, + Tradable: row.Tradable || row.IsTradable, + Fractional: row.Fractional || row.FractionalOk || row.FractionalTrad, + }) + } + return nil +} + +func (r *FundamentalsResult) UnmarshalJSON(b []byte) error { + type row struct { + Symbol string `json:"symbol"` + AverageVolume any `json:"average_volume"` + AvgVolume any `json:"avg_volume"` + Type string `json:"type"` + Instrument string `json:"instrument_kind"` + MarketCap any `json:"market_cap"` + PE any `json:"pe_ratio"` + High52 any `json:"high_52_weeks"` + Low52 any `json:"low_52_weeks"` + } + rows, _, err := wire.UnmarshalRows[row](b, "fundamentals", "results") + if err != nil { + return err + } + if len(rows) == 0 { + var one row + if json.Unmarshal(wire.Unwrap(b), &one) == nil && one.Symbol != "" { + rows = []row{one} + } + } + r.Fundamentals = make([]Fundamentals, 0, len(rows)) + for _, row := range rows { + kind := row.Instrument + if kind == "" { + kind = row.Type + } + avg, err := firstDec(row.AverageVolume, row.AvgVolume) + if err != nil { + return err + } + cap, err := firstDec(row.MarketCap) + if err != nil { + return err + } + pe, err := firstDec(row.PE) + if err != nil { + return err + } + hi, err := firstDec(row.High52) + if err != nil { + return err + } + lo, err := firstDec(row.Low52) + if err != nil { + return err + } + r.Fundamentals = append(r.Fundamentals, Fundamentals{ + Symbol: row.Symbol, + AvgVolume: avg, + InstrumentKind: kind, + MarketCap: cap, + PE: pe, + High52Week: hi, + Low52Week: lo, + }) + } + return nil +} + +func (r *PriceBookResult) UnmarshalJSON(b []byte) error { + type level struct { + Price any `json:"price"` + Quantity any `json:"quantity"` + Size any `json:"size"` + } + type book struct { + Symbol string `json:"symbol"` + Bids []level `json:"bids"` + Asks []level `json:"asks"` + } + rows, _, err := wire.UnmarshalRows[book](b, "books", "price_books", "results") + if err != nil { + return err + } + if len(rows) == 0 { + var one book + if json.Unmarshal(wire.Unwrap(b), &one) == nil && one.Symbol != "" { + rows = []book{one} + } + } + levels := func(in []level) ([]BookLevel, error) { + out := make([]BookLevel, 0, len(in)) + for _, lv := range in { + px, err := firstDec(lv.Price) + if err != nil { + return nil, err + } + qty, err := firstDec(lv.Quantity, lv.Size) + if err != nil { + return nil, err + } + out = append(out, BookLevel{Price: px, Quantity: qty}) + } + return out, nil + } + r.Books = make([]PriceBook, 0, len(rows)) + for _, row := range rows { + bids, err := levels(row.Bids) + if err != nil { + return err + } + asks, err := levels(row.Asks) + if err != nil { + return err + } + r.Books = append(r.Books, PriceBook{Symbol: row.Symbol, Bids: bids, Asks: asks}) + } + return nil +} + +func (r *TechnicalIndicatorsResult) UnmarshalJSON(b []byte) error { + type row struct { + BeginsAt string `json:"begins_at"` + Time string `json:"time"` + Timestamp string `json:"timestamp"` + Value any `json:"value"` + Upper any `json:"upper"` + Lower any `json:"lower"` + MACD any `json:"macd"` + Signal any `json:"signal"` + } + rows, _, err := wire.UnmarshalRows[row](b, "data_points", "points", "results", "values") + if err != nil { + return err + } + r.Points = make([]IndicatorPoint, 0, len(rows)) + for _, row := range rows { + s := row.BeginsAt + if s == "" { + s = row.Time + } + if s == "" { + s = row.Timestamp + } + ts, err := time.Parse(time.RFC3339, s) + if err != nil { + continue + } + val, err := firstDec(row.Value) + if err != nil { + return err + } + up, err := firstDec(row.Upper) + if err != nil { + return err + } + lo, err := firstDec(row.Lower) + if err != nil { + return err + } + macd, err := firstDec(row.MACD) + if err != nil { + return err + } + sig, err := firstDec(row.Signal) + if err != nil { + return err + } + r.Points = append(r.Points, IndicatorPoint{Time: ts, Value: val, Upper: up, Lower: lo, MACD: macd, Signal: sig}) + } + return nil +} + +func (r *NewsResult) UnmarshalJSON(b []byte) error { + type row struct { + Title string `json:"title"` + URL string `json:"url"` + PublishedAt string `json:"published_at"` + Source string `json:"source"` + Summary string `json:"summary"` + } + rows, next, err := wire.UnmarshalRows[row](b, "news", "articles", "results") + if err != nil { + return err + } + r.NextCursor = next + r.Articles = make([]NewsArticle, 0, len(rows)) + for _, row := range rows { + r.Articles = append(r.Articles, NewsArticle{ + Title: row.Title, + URL: row.URL, + PublishedAt: row.PublishedAt, + Source: row.Source, + Summary: row.Summary, + }) + } + return nil +} diff --git a/equity/results_test.go b/equity/results_test.go new file mode 100644 index 0000000..32f86e3 --- /dev/null +++ b/equity/results_test.go @@ -0,0 +1,70 @@ +package equity_test + +import ( + "context" + "encoding/json" + "testing" + + decimal "github.com/alpacahq/alpacadecimal" + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/equity" +) + +func TestPositions_parsesEnvelope(t *testing.T) { + t.Parallel() + c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + return json.RawMessage(`{"positions":[{"symbol":"MU","quantity":"3","average_buy_price":"99.6"}],"next_cursor":"n1"}`), nil + })) + got, err := c.Positions(context.Background(), equity.PositionsRequest{AccountNumber: "acct"}) + if err != nil { + t.Fatal(err) + } + if got.NextCursor != "n1" || len(got.Positions) != 1 || got.Positions[0].Symbol != "MU" { + t.Fatalf("%+v", got) + } + if !got.Positions[0].Qty.Equal(decimal.NewFromInt(3)) || !got.Positions[0].AvgCost.Equal(decimal.RequireFromString("99.6")) { + t.Fatalf("money %+v", got.Positions[0]) + } +} + +func TestOrders_parsesList(t *testing.T) { + t.Parallel() + c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + return json.RawMessage(`{"orders":[{"id":"o1","symbol":"MU","state":"filled","quantity":"2"}]}`), nil + })) + got, err := c.Orders(context.Background(), equity.OrdersRequest{AccountNumber: "acct"}) + if err != nil { + t.Fatal(err) + } + if len(got.Orders) != 1 || got.Orders[0].ID != "o1" || !got.Orders[0].Qty.Equal(decimal.NewFromInt(2)) { + t.Fatalf("%+v", got) + } +} + +func TestTradability_parsesFlags(t *testing.T) { + t.Parallel() + c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + return json.RawMessage(`{"symbol":"MU","tradable":true,"fractional_tradable":true}`), nil + })) + got, err := c.Tradability(context.Background(), equity.TradabilityRequest{AccountNumber: "acct", Symbols: []string{"MU"}}) + if err != nil { + t.Fatal(err) + } + if len(got.Symbols) != 1 || !got.Symbols[0].Tradable || !got.Symbols[0].Fractional { + t.Fatalf("%+v", got) + } +} + +func TestNews_parsesArticles(t *testing.T) { + t.Parallel() + c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + return json.RawMessage(`{"results":[{"title":"Hi","url":"https://x","published_at":"2026-01-01T00:00:00Z"}],"next_cursor":"c9"}`), nil + })) + got, err := c.News(context.Background(), equity.NewsRequest{Symbol: "MU"}) + if err != nil { + t.Fatal(err) + } + if got.NextCursor != "c9" || len(got.Articles) != 1 || got.Articles[0].Title != "Hi" { + t.Fatalf("%+v", got) + } +} diff --git a/equity/write.go b/equity/write.go index d61876a..c2d0d35 100644 --- a/equity/write.go +++ b/equity/write.go @@ -2,6 +2,7 @@ package equity import ( "context" + "encoding/json" decimal "github.com/alpacahq/alpacadecimal" "s1d3sw1ped/robinhood-agentic-mcp/client" @@ -42,9 +43,73 @@ type ReviewResult struct { Warnings []string `json:"warnings"` } -// Order is a placed equity order. +// Order is a placed or listed equity order. type Order struct { - ID string `json:"id"` + ID string `json:"id"` + IdempotencyKey string `json:"idempotency_key"` + Symbol string `json:"symbol"` + Side string `json:"side"` + Qty decimal.Decimal `json:"-"` + FilledQty decimal.Decimal `json:"-"` + Price decimal.Decimal `json:"-"` + State string `json:"state"` +} + +func (o *Order) UnmarshalJSON(b []byte) error { + var row struct { + ID string `json:"id"` + IdempotencyKey string `json:"idempotency_key"` + Symbol string `json:"symbol"` + Side string `json:"side"` + Quantity any `json:"quantity"` + Qty any `json:"qty"` + FilledQty any `json:"filled_quantity"` + CumulativeQty any `json:"cumulative_quantity"` + Price any `json:"price"` + AveragePrice any `json:"average_price"` + State string `json:"state"` + Status string `json:"status"` + } + if err := json.Unmarshal(b, &row); err != nil { + return err + } + o.ID = row.ID + o.IdempotencyKey = row.IdempotencyKey + o.Symbol = row.Symbol + o.Side = row.Side + o.State = row.State + if o.State == "" { + o.State = row.Status + } + var err error + if o.Qty, err = firstDec(row.Quantity, row.Qty); err != nil { + return err + } + if o.FilledQty, err = firstDec(row.FilledQty, row.CumulativeQty); err != nil { + return err + } + if o.Price, err = firstDec(row.Price, row.AveragePrice); err != nil { + return err + } + return nil +} + +func parseOrders(raw json.RawMessage) ([]Order, error) { + rows, _, err := wire.UnmarshalRows[Order](raw, "orders", "results") + if err != nil { + return nil, err + } + if len(rows) > 0 { + return rows, nil + } + var one Order + if err := json.Unmarshal(wire.Unwrap(raw), &one); err != nil { + return nil, err + } + if one.ID != "" { + return []Order{one}, nil + } + return nil, nil } // CancelOrderRequest is the argument set for cancel_equity_order. @@ -62,13 +127,23 @@ func (c *Client) ReviewOrder(ctx context.Context, req PlaceOrderRequest) (Review return out, nil } -// PlaceOrder calls place_equity_order. RefID is sent as both ref_id and idempotency_key. +// PlaceOrder calls place_equity_order. RefID is sent as ref_id only. func (c *Client) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (Order, error) { - var out Order - if err := c.parse(ctx, toolPlace, placeArgs(req, true), &out); err != nil { + raw, err := c.c.Call(ctx, toolPlace, placeArgs(req, true)) + if err != nil { return Order{}, err } - return out, nil + ords, err := parseOrders(raw) + if err != nil { + return Order{}, client.ToolErrorf(toolPlace, "parse: %w", err) + } + if len(ords) == 1 { + return ords[0], nil + } + if len(ords) > 1 { + return ords[0], nil + } + return Order{}, nil } // CancelOrder calls cancel_equity_order. @@ -128,7 +203,6 @@ func placeArgs(req PlaceOrderRequest, withRef bool) map[string]any { } if withRef && req.RefID != "" { args["ref_id"] = req.RefID - args["idempotency_key"] = req.RefID } return args } diff --git a/equity/write_test.go b/equity/write_test.go index f888367..9fcd638 100644 --- a/equity/write_test.go +++ b/equity/write_test.go @@ -40,6 +40,26 @@ func TestPlaceOrder_decimalStrings(t *testing.T) { if got["ref_id"] != "buy:2026-08-18:MU" { t.Fatalf("ref %v", got["ref_id"]) } + if _, ok := got["idempotency_key"]; ok { + t.Fatalf("idempotency_key %v", got["idempotency_key"]) + } +} + +func TestPlaceOrder_ordersEnvelope(t *testing.T) { + t.Parallel() + c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + return json.RawMessage(`{"orders":[{"id":"o2","symbol":"MU","side":"buy","quantity":"3","state":"confirmed"}]}`), nil + })) + got, err := c.PlaceOrder(context.Background(), equity.PlaceOrderRequest{Symbol: "MU"}) + if err != nil { + t.Fatal(err) + } + if got.ID != "o2" || got.Symbol != "MU" || got.Side != "buy" || got.State != "confirmed" { + t.Fatalf("%+v", got) + } + if !got.Qty.Equal(decimal.NewFromInt(3)) { + t.Fatalf("qty %s", got.Qty) + } } func TestEquity_writeArgs(t *testing.T) { @@ -98,14 +118,13 @@ func TestEquity_writeArgs(t *testing.T) { }, wantName: "place_equity_order", wantArgs: map[string]any{ - "account_number": "acct", - "symbol": "MU", - "side": "sell", - "type": "stop_market", - "quantity": "3", - "stop_price": "99.6", - "ref_id": "sell:2026-08-18:MU", - "idempotency_key": "sell:2026-08-18:MU", + "account_number": "acct", + "symbol": "MU", + "side": "sell", + "type": "stop_market", + "quantity": "3", + "stop_price": "99.6", + "ref_id": "sell:2026-08-18:MU", }, }, { @@ -135,8 +154,7 @@ func TestEquity_writeArgs(t *testing.T) { "tax_lots": []map[string]any{ {"open_lot_id": "lot-1", "quantity": "1.5"}, }, - "ref_id": "sell:lots", - "idempotency_key": "sell:lots", + "ref_id": "sell:lots", }, }, { @@ -182,6 +200,11 @@ func TestEquity_writeArgs(t *testing.T) { t.Fatalf("time_in_force injected: %+v", gotArgs) } } + if tc.wantName == "place_equity_order" { + if _, ok := gotArgs["idempotency_key"]; ok { + t.Fatalf("idempotency_key on place: %+v", gotArgs) + } + } }) } } diff --git a/internal/wire/list.go b/internal/wire/list.go new file mode 100644 index 0000000..208aa69 --- /dev/null +++ b/internal/wire/list.go @@ -0,0 +1,76 @@ +package wire + +import ( + "encoding/json" + "net/url" + "strings" +) + +// UnmarshalRows unpacks a list from an object (trying keys in order) or a JSON array. +// next is next_cursor, else the cursor query param of next, else next itself when it +// is not a URL. +func UnmarshalRows[T any](raw json.RawMessage, keys ...string) (rows []T, next string, err error) { + data := Unwrap(raw) + var obj map[string]json.RawMessage + if json.Unmarshal(data, &obj) != nil { + var list []T + if err := json.Unmarshal(data, &list); err != nil { + return nil, "", err + } + return list, "", nil + } + next = cursorFromMap(obj) + for _, k := range keys { + item, ok := obj[k] + if !ok || len(item) == 0 || string(item) == "null" { + continue + } + var list []T + if json.Unmarshal(item, &list) == nil { + return list, next, nil + } + var one T + if json.Unmarshal(item, &one) == nil { + return []T{one}, next, nil + } + } + return nil, next, nil +} + +func cursorFromMap(obj map[string]json.RawMessage) string { + if c := stringField(obj, "next_cursor"); c != "" { + return c + } + return Cursor(stringField(obj, "next"), "") +} + +func stringField(obj map[string]json.RawMessage, key string) string { + raw, ok := obj[key] + if !ok || len(raw) == 0 || string(raw) == "null" { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + return "" +} + +// Cursor prefers nextCursor; otherwise extracts cursor from a next URL. +func Cursor(next, nextCursor string) string { + if nextCursor != "" { + return nextCursor + } + next = strings.TrimSpace(next) + if next == "" { + return "" + } + u, err := url.Parse(next) + if err != nil || (u.Scheme == "" && u.Host == "" && !strings.Contains(next, "?")) { + return next + } + if v := u.Query().Get("cursor"); v != "" { + return v + } + return "" +} diff --git a/internal/wire/list_test.go b/internal/wire/list_test.go new file mode 100644 index 0000000..1fcfc7b --- /dev/null +++ b/internal/wire/list_test.go @@ -0,0 +1,49 @@ +package wire_test + +import ( + "encoding/json" + "testing" + + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +func TestUnmarshalRows_objectAndArray(t *testing.T) { + t.Parallel() + type row struct { + ID string `json:"id"` + } + rows, next, err := wire.UnmarshalRows[row](json.RawMessage(`{"data":{"positions":[{"id":"a"}],"next_cursor":"c2"}}`), "positions", "results") + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].ID != "a" || next != "c2" { + t.Fatalf("%+v %q", rows, next) + } + rows, next, err = wire.UnmarshalRows[row](json.RawMessage(`[{"id":"b"}]`), "positions") + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].ID != "b" || next != "" { + t.Fatalf("%+v %q", rows, next) + } + rows, next, err = wire.UnmarshalRows[row](json.RawMessage(`{"results":{"id":"one"},"next":"https://x/?cursor=n3"}`), "positions", "results") + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].ID != "one" || next != "n3" { + t.Fatalf("%+v %q", rows, next) + } +} + +func TestCursor(t *testing.T) { + t.Parallel() + if wire.Cursor("https://x/y?cursor=abc", "") != "abc" { + t.Fatal("url") + } + if wire.Cursor("rawtok", "pref") != "pref" { + t.Fatal("pref") + } + if wire.Cursor("rawtok", "") != "rawtok" { + t.Fatal("bare") + } +} diff --git a/market/market.go b/market/market.go index 8034e1a..134ebab 100644 --- a/market/market.go +++ b/market/market.go @@ -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) { diff --git a/market/market_test.go b/market/market_test.go index 292ca6b..c5347cc 100644 --- a/market/market_test.go +++ b/market/market_test.go @@ -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) + } +} diff --git a/market/results.go b/market/results.go new file mode 100644 index 0000000..9745c8a --- /dev/null +++ b/market/results.go @@ -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 +} diff --git a/options/read.go b/options/read.go index 9c70d40..08942e3 100644 --- a/options/read.go +++ b/options/read.go @@ -5,6 +5,7 @@ import ( "encoding/json" "time" + decimal "github.com/alpacahq/alpacadecimal" "s1d3sw1ped/robinhood-agentic-mcp/client" "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" ) @@ -24,8 +25,17 @@ type ChainsRequest struct { UnderlyingSymbol string } +// OptionChain is one chain from get_option_chains. +type OptionChain struct { + ID string + UnderlyingSymbol string + ExpirationDates []string +} + // ChainsResult is the parsed get_option_chains payload. -type ChainsResult struct{} +type ChainsResult struct { + Chains []OptionChain +} // InstrumentsRequest is the argument set for get_option_instruments. type InstrumentsRequest struct { @@ -40,16 +50,42 @@ type InstrumentsRequest struct { Cursor string } +// OptionInstrument is one contract from get_option_instruments. +type OptionInstrument struct { + ID string + ChainID string + ChainSymbol string + ExpirationDate string + StrikePrice decimal.Decimal + Type string + State string +} + // InstrumentsResult is the parsed get_option_instruments payload. -type InstrumentsResult struct{} +type InstrumentsResult struct { + Instruments []OptionInstrument + NextCursor string +} // QuotesRequest is the argument set for get_option_quotes. type QuotesRequest struct { InstrumentIDs []string } +// OptionQuote is one contract quote from get_option_quotes. +type OptionQuote struct { + InstrumentID string + Bid decimal.Decimal + Ask decimal.Decimal + Last decimal.Decimal + PrevClose decimal.Decimal + Mark decimal.Decimal +} + // QuotesResult is the parsed get_option_quotes payload. -type QuotesResult struct{} +type QuotesResult struct { + Quotes []OptionQuote +} // PositionsRequest is the argument set for get_option_positions. type PositionsRequest struct { @@ -65,8 +101,22 @@ type PositionsRequest struct { Cursor string } +// OptionPosition is one holding from get_option_positions. +type OptionPosition struct { + OptionID string + ChainID string + Type string + OptionType string + Quantity decimal.Decimal + AveragePrice decimal.Decimal + ExpirationDate string +} + // PositionsResult is the parsed get_option_positions payload. -type PositionsResult struct{} +type PositionsResult struct { + Positions []OptionPosition + NextCursor string +} // OrdersRequest is the argument set for get_option_orders. type OrdersRequest struct { @@ -81,7 +131,10 @@ type OrdersRequest struct { } // OrdersResult is the parsed get_option_orders payload. -type OrdersResult struct{} +type OrdersResult struct { + Orders []Order + NextCursor string +} // HistoricalsRequest is the argument set for get_option_historicals. type HistoricalsRequest struct { @@ -92,8 +145,19 @@ type HistoricalsRequest struct { Bounds string } +// OptionBar is one OHLC bar from get_option_historicals. +type OptionBar struct { + InstrumentID string + Time time.Time + Open, High, Low, Close decimal.Decimal + Volume decimal.Decimal + Interpolated bool +} + // HistoricalsResult is the parsed get_option_historicals payload. -type HistoricalsResult struct{} +type HistoricalsResult struct { + Bars []OptionBar +} // Chains calls get_option_chains. func (c *Client) Chains(ctx context.Context, req ChainsRequest) (ChainsResult, error) { diff --git a/options/results.go b/options/results.go new file mode 100644 index 0000000..813c96e --- /dev/null +++ b/options/results.go @@ -0,0 +1,275 @@ +package options + +import ( + "encoding/json" + "time" + + decimal "github.com/alpacahq/alpacadecimal" + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +func (o *Order) UnmarshalJSON(b []byte) error { + var row struct { + ID string `json:"id"` + State string `json:"state"` + Status string `json:"status"` + Quantity any `json:"quantity"` + Price any `json:"price"` + } + if err := json.Unmarshal(b, &row); err != nil { + return err + } + o.ID = row.ID + o.State = row.State + if o.State == "" { + o.State = row.Status + } + var err error + if o.Qty, err = firstDec(row.Quantity); err != nil { + return err + } + if o.Price, err = firstDec(row.Price); err != nil { + return err + } + return nil +} + +func parseOrders(raw json.RawMessage) ([]Order, error) { + rows, _, err := wire.UnmarshalRows[Order](raw, "orders", "results") + if err != nil { + return nil, err + } + if len(rows) > 0 { + return rows, nil + } + var one Order + if err := json.Unmarshal(wire.Unwrap(raw), &one); err != nil { + return nil, err + } + if one.ID != "" { + return []Order{one}, nil + } + return nil, nil +} + +func (r *ChainsResult) UnmarshalJSON(b []byte) error { + type row struct { + ID string `json:"id"` + UnderlyingSymbol string `json:"underlying_symbol"` + Symbol string `json:"symbol"` + ExpirationDates []string `json:"expiration_dates"` + } + rows, _, err := wire.UnmarshalRows[row](b, "results", "chains") + if err != nil { + return err + } + r.Chains = make([]OptionChain, 0, len(rows)) + for _, row := range rows { + sym := row.UnderlyingSymbol + if sym == "" { + sym = row.Symbol + } + r.Chains = append(r.Chains, OptionChain{ID: row.ID, UnderlyingSymbol: sym, ExpirationDates: row.ExpirationDates}) + } + return nil +} + +func (r *InstrumentsResult) UnmarshalJSON(b []byte) error { + type row struct { + ID string `json:"id"` + ChainID string `json:"chain_id"` + ChainSymbol string `json:"chain_symbol"` + ExpirationDate string `json:"expiration_date"` + StrikePrice any `json:"strike_price"` + Type string `json:"type"` + State string `json:"state"` + } + rows, next, err := wire.UnmarshalRows[row](b, "results", "instruments") + if err != nil { + return err + } + r.NextCursor = next + r.Instruments = make([]OptionInstrument, 0, len(rows)) + for _, row := range rows { + px, err := firstDec(row.StrikePrice) + if err != nil { + return err + } + r.Instruments = append(r.Instruments, OptionInstrument{ + ID: row.ID, ChainID: row.ChainID, ChainSymbol: row.ChainSymbol, + ExpirationDate: row.ExpirationDate, StrikePrice: px, Type: row.Type, State: row.State, + }) + } + return nil +} + +func (r *QuotesResult) UnmarshalJSON(b []byte) error { + type row struct { + InstrumentID string `json:"instrument_id"` + ID string `json:"id"` + Bid any `json:"bid"` + BidPrice any `json:"bid_price"` + Ask any `json:"ask"` + AskPrice any `json:"ask_price"` + Last any `json:"last"` + LastPrice any `json:"last_trade_price"` + PrevClose any `json:"previous_close"` + Mark any `json:"mark"` + MarkPrice any `json:"mark_price"` + } + rows, _, err := wire.UnmarshalRows[row](b, "quotes", "results") + if err != nil { + return err + } + r.Quotes = make([]OptionQuote, 0, len(rows)) + for _, row := range rows { + id := row.InstrumentID + if id == "" { + id = row.ID + } + bid, err := firstDec(row.BidPrice, row.Bid) + if err != nil { + return err + } + ask, err := firstDec(row.AskPrice, row.Ask) + if err != nil { + return err + } + last, err := firstDec(row.LastPrice, row.Last) + if err != nil { + return err + } + prev, err := firstDec(row.PrevClose) + if err != nil { + return err + } + mark, err := firstDec(row.MarkPrice, row.Mark) + if err != nil { + return err + } + r.Quotes = append(r.Quotes, OptionQuote{InstrumentID: id, Bid: bid, Ask: ask, Last: last, PrevClose: prev, Mark: mark}) + } + return nil +} + +func (r *PositionsResult) UnmarshalJSON(b []byte) error { + type row struct { + OptionID string `json:"option_id"` + ID string `json:"id"` + ChainID string `json:"chain_id"` + Type string `json:"type"` + OptionType string `json:"option_type"` + Quantity any `json:"quantity"` + AveragePrice any `json:"average_price"` + ExpirationDate string `json:"expiration_date"` + } + rows, next, err := wire.UnmarshalRows[row](b, "results", "positions") + if err != nil { + return err + } + r.NextCursor = next + r.Positions = make([]OptionPosition, 0, len(rows)) + for _, row := range rows { + id := row.OptionID + if id == "" { + id = row.ID + } + qty, err := firstDec(row.Quantity) + if err != nil { + return err + } + avg, err := firstDec(row.AveragePrice) + if err != nil { + return err + } + r.Positions = append(r.Positions, OptionPosition{ + OptionID: id, ChainID: row.ChainID, Type: row.Type, OptionType: row.OptionType, + Quantity: qty, AveragePrice: avg, ExpirationDate: row.ExpirationDate, + }) + } + return nil +} + +func (r *OrdersResult) UnmarshalJSON(b []byte) error { + rows, next, err := wire.UnmarshalRows[Order](b, "orders", "results") + if err != nil { + return err + } + if len(rows) == 0 { + var one Order + if json.Unmarshal(wire.Unwrap(b), &one) == nil && one.ID != "" { + rows = []Order{one} + } + } + r.Orders = rows + r.NextCursor = next + return nil +} + +func (r *HistoricalsResult) 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"` + Volume any `json:"volume"` + 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 + } + vol, err := firstDec(p.Volume) + if err != nil { + return err + } + r.Bars = append(r.Bars, OptionBar{InstrumentID: id, Time: ts, Open: o, High: h, Low: l, Close: cl, Volume: vol, Interpolated: p.Interpolated}) + } + } + return nil +} + +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 +} diff --git a/options/write.go b/options/write.go index f255b28..8650e05 100644 --- a/options/write.go +++ b/options/write.go @@ -57,11 +57,18 @@ type ReplaceOrderRequest struct { } // ReviewResult is the pre-trade check from review_option_order. -type ReviewResult struct{} +type ReviewResult struct { + Errors []string `json:"errors"` + Warnings []string `json:"warnings"` + Alerts []string `json:"alerts"` +} -// Order is a placed or replaced option order. +// Order is a placed, replaced, or listed option order. type Order struct { - ID string `json:"id"` + ID string `json:"id"` + State string `json:"state"` + Qty decimal.Decimal `json:"-"` + Price decimal.Decimal `json:"-"` } // CancelOrderRequest is the argument set for cancel_option_order. @@ -81,7 +88,10 @@ type ExerciseRequest struct { } // ExerciseResult is the parsed exercise_option payload. -type ExerciseResult struct{} +type ExerciseResult struct { + ID string `json:"id"` + State string `json:"state"` +} // CancelExerciseRequest is the argument set for cancel_option_exercise. type CancelExerciseRequest struct { @@ -100,11 +110,18 @@ func (c *Client) ReviewOrder(ctx context.Context, req PlaceOrderRequest) (Review // PlaceOrder calls place_option_order. RefID is sent as ref_id. func (c *Client) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (Order, error) { - var out Order - if err := c.parse(ctx, toolPlace, placeArgs(req, true, false), &out); err != nil { + raw, err := c.c.Call(ctx, toolPlace, placeArgs(req, true, false)) + if err != nil { return Order{}, err } - return out, nil + ords, err := parseOrders(raw) + if err != nil { + return Order{}, client.ToolErrorf(toolPlace, "parse: %w", err) + } + if len(ords) > 0 { + return ords[0], nil + } + return Order{}, nil } // CancelOrder calls cancel_option_order. @@ -137,11 +154,18 @@ func (c *Client) ReplaceOrder(ctx context.Context, req ReplaceOrderRequest) (Ord if req.OrderID != "" { args["order_id"] = req.OrderID } - var out Order - if err := c.parse(ctx, toolReplace, args, &out); err != nil { + raw, err := c.c.Call(ctx, toolReplace, args) + if err != nil { return Order{}, err } - return out, nil + ords, err := parseOrders(raw) + if err != nil { + return Order{}, client.ToolErrorf(toolReplace, "parse: %w", err) + } + if len(ords) > 0 { + return ords[0], nil + } + return Order{}, nil } // Exercise calls exercise_option. diff --git a/scanner/results.go b/scanner/results.go new file mode 100644 index 0000000..d88b50a --- /dev/null +++ b/scanner/results.go @@ -0,0 +1,111 @@ +package scanner + +import ( + "encoding/json" + + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +func (r *FilterSpecsResult) UnmarshalJSON(b []byte) error { + type row struct { + FilterType string `json:"filter_type"` + Name string `json:"name"` + } + rows, _, err := wire.UnmarshalRows[row](b, "specs", "filters", "results") + if err != nil { + return err + } + r.Specs = make([]FilterSpec, 0, len(rows)) + for _, row := range rows { + r.Specs = append(r.Specs, FilterSpec{FilterType: row.FilterType, Name: row.Name}) + } + return nil +} + +func (r *DatapointsResult) UnmarshalJSON(b []byte) error { + type row struct { + Name string `json:"name"` + } + rows, _, err := wire.UnmarshalRows[row](b, "datapoints", "results") + if err != nil { + return err + } + r.Datapoints = make([]Datapoint, 0, len(rows)) + for _, row := range rows { + r.Datapoints = append(r.Datapoints, Datapoint{Name: row.Name}) + } + return nil +} + +func unmarshalScan(b []byte) (ScanResult, error) { + var wrap struct { + ID string `json:"id"` + ScanID string `json:"scan_id"` + Title string `json:"title"` + Total int `json:"total"` + Rows []struct { + Symbol string `json:"symbol"` + Ticker string `json:"ticker"` + InstrumentID string `json:"instrument_id"` + } `json:"rows"` + Results []struct { + Symbol string `json:"symbol"` + InstrumentID string `json:"instrument_id"` + } `json:"results"` + } + if err := json.Unmarshal(wire.Unwrap(b), &wrap); err != nil { + return ScanResult{}, err + } + out := ScanResult{ID: wrap.ID, Title: wrap.Title, Total: wrap.Total} + if out.ID == "" { + out.ID = wrap.ScanID + } + src := wrap.Rows + if len(src) == 0 { + for _, row := range wrap.Results { + src = append(src, struct { + Symbol string `json:"symbol"` + Ticker string `json:"ticker"` + InstrumentID string `json:"instrument_id"` + }{Symbol: row.Symbol, InstrumentID: row.InstrumentID}) + } + } + for _, row := range src { + sym := row.Symbol + if sym == "" { + sym = row.Ticker + } + out.Rows = append(out.Rows, ScanRow{Symbol: sym, InstrumentID: row.InstrumentID}) + } + return out, nil +} + +func (r *CreateResult) UnmarshalJSON(b []byte) error { + s, err := unmarshalScan(b) + r.ScanResult = s + return err +} + +func (r *PreviewResult) UnmarshalJSON(b []byte) error { + s, err := unmarshalScan(b) + r.ScanResult = s + return err +} + +func (r *RunResult) UnmarshalJSON(b []byte) error { + s, err := unmarshalScan(b) + r.ScanResult = s + return err +} + +func (r *UpdateFiltersResult) UnmarshalJSON(b []byte) error { + s, err := unmarshalScan(b) + r.ScanResult = s + return err +} + +func (r *UpdateConfigResult) UnmarshalJSON(b []byte) error { + s, err := unmarshalScan(b) + r.ScanResult = s + return err +} diff --git a/scanner/scanner.go b/scanner/scanner.go index 2d142a0..b0c772d 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -42,14 +42,29 @@ type Column struct { // FilterSpecsRequest is the argument set for get_scanner_filter_specs (none). type FilterSpecsRequest struct{} +// FilterSpec is one filter type from get_scanner_filter_specs. +type FilterSpec struct { + FilterType string `json:"filter_type"` + Name string `json:"name"` +} + // FilterSpecsResult is the parsed get_scanner_filter_specs payload. -type FilterSpecsResult struct{} +type FilterSpecsResult struct { + Specs []FilterSpec +} // DatapointsRequest is the argument set for get_scanner_datapoints (none). type DatapointsRequest struct{} +// Datapoint is one expression token from get_scanner_datapoints. +type Datapoint struct { + Name string `json:"name"` +} + // DatapointsResult is the parsed get_scanner_datapoints payload. -type DatapointsResult struct{} +type DatapointsResult struct { + Datapoints []Datapoint +} // ScansRequest is the argument set for get_scans (none). type ScansRequest struct{} @@ -74,8 +89,24 @@ type CreateRequest struct { Title string } +// ScanResult is a saved scan plus optional live rows. +type ScanResult struct { + ID string + Title string + Rows []ScanRow + Total int +} + +// ScanRow is one live scanner match. +type ScanRow struct { + Symbol string + InstrumentID string +} + // CreateResult is the parsed create_scan payload. -type CreateResult struct{} +type CreateResult struct { + ScanResult +} // PreviewRequest is the argument set for preview_scan. type PreviewRequest struct { @@ -84,7 +115,9 @@ type PreviewRequest struct { } // PreviewResult is the parsed preview_scan payload. -type PreviewResult struct{} +type PreviewResult struct { + ScanResult +} // RunRequest is the argument set for run_scan. type RunRequest struct { @@ -92,7 +125,9 @@ type RunRequest struct { } // RunResult is the parsed run_scan payload. -type RunResult struct{} +type RunResult struct { + ScanResult +} // UpdateFiltersRequest is the argument set for update_scan_filters. type UpdateFiltersRequest struct { @@ -101,7 +136,9 @@ type UpdateFiltersRequest struct { } // UpdateFiltersResult is the parsed update_scan_filters payload. -type UpdateFiltersResult struct{} +type UpdateFiltersResult struct { + ScanResult +} // UpdateConfigRequest is the argument set for update_scan_config. type UpdateConfigRequest struct { @@ -112,7 +149,9 @@ type UpdateConfigRequest struct { } // UpdateConfigResult is the parsed update_scan_config payload. -type UpdateConfigResult struct{} +type UpdateConfigResult struct { + ScanResult +} // FilterSpecs calls get_scanner_filter_specs. func (c *Client) FilterSpecs(ctx context.Context, req FilterSpecsRequest) (FilterSpecsResult, error) { diff --git a/watchlists/results.go b/watchlists/results.go new file mode 100644 index 0000000..0330953 --- /dev/null +++ b/watchlists/results.go @@ -0,0 +1,55 @@ +package watchlists + +import ( + "encoding/json" + + "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" +) + +func (r *OptionListResult) UnmarshalJSON(b []byte) error { + type row struct { + OptionID string `json:"option_id"` + ID string `json:"id"` + Symbol string `json:"symbol"` + Title string `json:"title"` + PositionType string `json:"position_type"` + } + rows, _, err := wire.UnmarshalRows[row](b, "items", "results", "options") + if err != nil { + return err + } + r.Items = make([]OptionWatchItem, 0, len(rows)) + for _, row := range rows { + id := row.OptionID + if id == "" { + id = row.ID + } + r.Items = append(r.Items, OptionWatchItem{ + OptionID: id, Symbol: row.Symbol, Title: row.Title, PositionType: row.PositionType, + }) + } + return nil +} + +func (r *PopularResult) UnmarshalJSON(b []byte) error { + type row struct { + ID string `json:"id"` + Title string `json:"title"` + Name string `json:"name"` + } + rows, _, err := wire.UnmarshalRows[row](b, "watchlists", "results") + if err != nil { + return err + } + r.Watchlists = make([]Watchlist, 0, len(rows)) + for _, row := range rows { + title := row.Title + if title == "" { + title = row.Name + } + r.Watchlists = append(r.Watchlists, Watchlist{ID: row.ID, Title: title}) + } + return nil +} + +var _ json.Unmarshaler = (*OptionListResult)(nil) diff --git a/watchlists/watchlists.go b/watchlists/watchlists.go index a7c7d90..336da8e 100644 --- a/watchlists/watchlists.go +++ b/watchlists/watchlists.go @@ -63,14 +63,26 @@ type ItemsResult struct { // OptionListRequest is the argument set for get_option_watchlist (none). type OptionListRequest struct{} +// OptionWatchItem is one contract on the options watchlist. +type OptionWatchItem struct { + OptionID string + Symbol string + Title string + PositionType string +} + // OptionListResult is the parsed get_option_watchlist payload. -type OptionListResult struct{} +type OptionListResult struct { + Items []OptionWatchItem +} // PopularRequest is the argument set for get_popular_watchlists (none). type PopularRequest struct{} // PopularResult is the parsed get_popular_watchlists payload. -type PopularResult struct{} +type PopularResult struct { + Watchlists []Watchlist +} // CreateRequest is the argument set for create_watchlist. type CreateRequest struct {