package equity import ( "context" "encoding/json" "time" decimal "github.com/alpacahq/alpacadecimal" "s1d3sw1ped/robinhood-agentic-mcp/client" "s1d3sw1ped/robinhood-agentic-mcp/internal/wire" ) const ( toolPositions = "get_equity_positions" toolTaxLots = "get_equity_tax_lots" toolQuotes = "get_equity_quotes" toolOrders = "get_equity_orders" toolTradability = "get_equity_tradability" toolHistoricals = "get_equity_historicals" toolFundamentals = "get_equity_fundamentals" toolPriceBook = "get_equity_price_book" toolTechnicalIndicators = "get_equity_technical_indicators" toolNews = "get_equity_news" ) // PositionsRequest is the argument set for get_equity_positions. type PositionsRequest struct { AccountNumber string 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 { Positions []Position NextCursor string } // TaxLotsRequest is the argument set for get_equity_tax_lots. type TaxLotsRequest struct { AccountNumber string Symbol string 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 { Lots []TaxLotRow NextCursor string } // QuotesRequest is the argument set for get_equity_quotes. type QuotesRequest struct { Symbols []string } // Quote is a top-of-book print from get_equity_quotes. type Quote struct { Symbol string Bid decimal.Decimal Ask decimal.Decimal Last decimal.Decimal PrevClose decimal.Decimal Volume decimal.Decimal } // QuotesResult is the parsed get_equity_quotes payload. type QuotesResult struct { Quotes []Quote } // OrdersRequest is the argument set for get_equity_orders. type OrdersRequest struct { AccountNumber string OrderID string State string Symbol string CreatedAtGTE string PlacedAgent string Cursor string } // OrdersResult is the parsed get_equity_orders payload. type OrdersResult struct { Orders []Order NextCursor string } // TradabilityRequest is the argument set for get_equity_tradability. type TradabilityRequest struct { AccountNumber string 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 { Symbols []SymbolTradability } // HistoricalsRequest is the argument set for get_equity_historicals. type HistoricalsRequest struct { Symbols []string StartTime time.Time EndTime time.Time Interval string Bounds string AdjustmentType string } // Bar is one equity historical candle from get_equity_historicals. type Bar struct { Symbol string Time time.Time Open, High, Low, Close decimal.Decimal Volume decimal.Decimal Interpolated bool } // HistoricalsResult is the parsed get_equity_historicals payload. type HistoricalsResult struct { Bars []Bar } // FundamentalsRequest is the argument set for get_equity_fundamentals. type FundamentalsRequest struct { Symbols []string 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 { 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 { Books []PriceBook } // TechnicalIndicatorsRequest is the argument set for get_equity_technical_indicators. type TechnicalIndicatorsRequest struct { Symbol string Type string Interval string StartTime time.Time EndTime time.Time Bounds string AdjustmentType string Output string Period *int NumStd *decimal.Decimal FastPeriod *int SlowPeriod *int SignalPeriod *int Multiplier *decimal.Decimal 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 { Points []IndicatorPoint } // NewsRequest is the argument set for get_equity_news. type NewsRequest struct { Symbol string Limit int 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 { Articles []NewsArticle NextCursor string } // Positions calls get_equity_positions. func (c *Client) Positions(ctx context.Context, req PositionsRequest) (PositionsResult, error) { args := map[string]any{} if req.AccountNumber != "" { args["account_number"] = req.AccountNumber } if req.Cursor != "" { args["cursor"] = req.Cursor } var out PositionsResult if err := c.parse(ctx, toolPositions, args, &out); err != nil { return PositionsResult{}, err } return out, nil } // TaxLots calls get_equity_tax_lots. func (c *Client) TaxLots(ctx context.Context, req TaxLotsRequest) (TaxLotsResult, error) { args := map[string]any{} if req.AccountNumber != "" { args["account_number"] = req.AccountNumber } if req.Symbol != "" { args["symbol"] = req.Symbol } if req.Cursor != "" { args["cursor"] = req.Cursor } var out TaxLotsResult if err := c.parse(ctx, toolTaxLots, args, &out); err != nil { return TaxLotsResult{}, err } return out, nil } // Quotes calls get_equity_quotes. func (c *Client) Quotes(ctx context.Context, req QuotesRequest) (QuotesResult, error) { args := map[string]any{} if len(req.Symbols) > 0 { args["symbols"] = req.Symbols } raw, err := c.c.Call(ctx, toolQuotes, args) if err != nil { return QuotesResult{}, err } quotes, err := parseQuotes(raw) if err != nil { return QuotesResult{}, client.ToolErrorf(toolQuotes, "parse: %w", err) } return QuotesResult{Quotes: quotes}, nil } // Orders calls get_equity_orders. func (c *Client) Orders(ctx context.Context, req OrdersRequest) (OrdersResult, error) { args := map[string]any{} if req.AccountNumber != "" { args["account_number"] = req.AccountNumber } if req.OrderID != "" { args["order_id"] = req.OrderID } if req.State != "" { args["state"] = req.State } if req.Symbol != "" { args["symbol"] = req.Symbol } if req.CreatedAtGTE != "" { args["created_at_gte"] = req.CreatedAtGTE } if req.PlacedAgent != "" { args["placed_agent"] = req.PlacedAgent } if req.Cursor != "" { args["cursor"] = req.Cursor } var out OrdersResult if err := c.parse(ctx, toolOrders, args, &out); err != nil { return OrdersResult{}, err } return out, nil } // Tradability calls get_equity_tradability. func (c *Client) Tradability(ctx context.Context, req TradabilityRequest) (TradabilityResult, error) { args := map[string]any{} if req.AccountNumber != "" { args["account_number"] = req.AccountNumber } if len(req.Symbols) > 0 { args["symbols"] = req.Symbols } var out TradabilityResult if err := c.parse(ctx, toolTradability, args, &out); err != nil { return TradabilityResult{}, err } return out, nil } // Historicals calls get_equity_historicals. func (c *Client) Historicals(ctx context.Context, req HistoricalsRequest) (HistoricalsResult, error) { args := map[string]any{ "start_time": req.StartTime.UTC().Format(time.RFC3339), } if len(req.Symbols) > 0 { args["symbols"] = req.Symbols } if !req.EndTime.IsZero() { args["end_time"] = req.EndTime.UTC().Format(time.RFC3339) } if req.Interval != "" { args["interval"] = req.Interval } if req.Bounds != "" { args["bounds"] = req.Bounds } if req.AdjustmentType != "" { args["adjustment_type"] = req.AdjustmentType } raw, err := c.c.Call(ctx, toolHistoricals, args) if err != nil { return HistoricalsResult{}, err } bars, err := parseHistoricals(raw) if err != nil { return HistoricalsResult{}, client.ToolErrorf(toolHistoricals, "parse: %w", err) } return HistoricalsResult{Bars: bars}, nil } // Fundamentals calls get_equity_fundamentals. func (c *Client) Fundamentals(ctx context.Context, req FundamentalsRequest) (FundamentalsResult, error) { args := map[string]any{} if len(req.Symbols) > 0 { args["symbols"] = req.Symbols } if req.Bounds != "" { args["bounds"] = req.Bounds } var out FundamentalsResult if err := c.parse(ctx, toolFundamentals, args, &out); err != nil { return FundamentalsResult{}, err } return out, nil } // PriceBook calls get_equity_price_book. func (c *Client) PriceBook(ctx context.Context, req PriceBookRequest) (PriceBookResult, error) { args := map[string]any{} if len(req.Symbols) > 0 { args["symbols"] = req.Symbols } var out PriceBookResult if err := c.parse(ctx, toolPriceBook, args, &out); err != nil { return PriceBookResult{}, err } return out, nil } // TechnicalIndicators calls get_equity_technical_indicators. func (c *Client) TechnicalIndicators(ctx context.Context, req TechnicalIndicatorsRequest) (TechnicalIndicatorsResult, error) { args := map[string]any{ "start_time": req.StartTime.UTC().Format(time.RFC3339), } if req.Symbol != "" { args["symbol"] = req.Symbol } if req.Type != "" { args["type"] = req.Type } if req.Interval != "" { args["interval"] = req.Interval } if !req.EndTime.IsZero() { args["end_time"] = req.EndTime.UTC().Format(time.RFC3339) } if req.Bounds != "" { args["bounds"] = req.Bounds } if req.AdjustmentType != "" { args["adjustment_type"] = req.AdjustmentType } if req.Output != "" { args["output"] = req.Output } if req.Period != nil { args["period"] = *req.Period } if req.NumStd != nil { args["num_std"] = json.Number(wire.Encode(*req.NumStd)) } if req.FastPeriod != nil { args["fast_period"] = *req.FastPeriod } if req.SlowPeriod != nil { args["slow_period"] = *req.SlowPeriod } if req.SignalPeriod != nil { args["signal_period"] = *req.SignalPeriod } if req.Multiplier != nil { args["multiplier"] = json.Number(wire.Encode(*req.Multiplier)) } if req.Method != "" { args["method"] = req.Method } var out TechnicalIndicatorsResult if err := c.parse(ctx, toolTechnicalIndicators, args, &out); err != nil { return TechnicalIndicatorsResult{}, err } return out, nil } // News calls get_equity_news. func (c *Client) News(ctx context.Context, req NewsRequest) (NewsResult, error) { args := map[string]any{} if req.Symbol != "" { args["symbol"] = req.Symbol } if req.Limit != 0 { args["limit"] = req.Limit } if req.Cursor != "" { args["cursor"] = req.Cursor } var out NewsResult if err := c.parse(ctx, toolNews, args, &out); err != nil { return NewsResult{}, 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 } type quoteFields struct { Symbol string `json:"symbol"` Last any `json:"last"` LastTradePrice any `json:"last_trade_price"` LastNonRegTradePrice any `json:"last_non_reg_trade_price"` Bid any `json:"bid"` BidPrice any `json:"bid_price"` Ask any `json:"ask"` AskPrice any `json:"ask_price"` PreviousClose any `json:"previous_close"` AdjustedPreviousClose any `json:"adjusted_previous_close"` Volume any `json:"volume"` } type quoteRow struct { Quote *quoteFields `json:"quote"` Close *struct { Symbol string `json:"symbol"` Price any `json:"price"` } `json:"close"` quoteFields } func (r quoteRow) asQuote() (Quote, bool, error) { f := r.quoteFields if r.Quote != nil { f = *r.Quote } if f.Symbol == "" && r.Close != nil { f.Symbol = r.Close.Symbol } if f.Symbol == "" { return Quote{}, false, nil } var closePx any if r.Close != nil { closePx = r.Close.Price } last, err := firstDec(f.LastTradePrice, f.Last, f.LastNonRegTradePrice) if err != nil { return Quote{}, false, err } prev, err := firstDec(closePx, f.PreviousClose, f.AdjustedPreviousClose) if err != nil { return Quote{}, false, err } bid, err := firstDec(f.BidPrice, f.Bid) if err != nil { return Quote{}, false, err } ask, err := firstDec(f.AskPrice, f.Ask) if err != nil { return Quote{}, false, err } vol, err := firstDec(f.Volume) if err != nil { return Quote{}, false, err } return Quote{ Symbol: f.Symbol, Last: last, PrevClose: prev, Bid: bid, Ask: ask, Volume: vol, }, true, nil } func parseQuotes(raw json.RawMessage) ([]Quote, error) { var wrap struct { Quotes []quoteRow `json:"quotes"` Results []quoteRow `json:"results"` } if err := json.Unmarshal(wire.Unwrap(raw), &wrap); err != nil { return nil, err } out := make([]Quote, 0, len(wrap.Quotes)+len(wrap.Results)) var err error out, err = appendQuotes(out, wrap.Quotes) if err != nil { return nil, err } out, err = appendQuotes(out, wrap.Results) if err != nil { return nil, err } return out, nil } func appendQuotes(dst []Quote, rows []quoteRow) ([]Quote, error) { for _, row := range rows { q, ok, err := row.asQuote() if err != nil { return nil, err } if !ok { continue } dst = append(dst, q) } return dst, nil } type histPointJSON struct { BeginsAt string `json:"begins_at"` Open any `json:"open"` OpenPrice any `json:"open_price"` High any `json:"high"` HighPrice any `json:"high_price"` Low any `json:"low"` LowPrice any `json:"low_price"` Close any `json:"close"` ClosePrice any `json:"close_price"` Volume any `json:"volume"` Interpolated bool `json:"interpolated"` } type histSeriesJSON struct { Symbol string `json:"symbol"` DataPoints []histPointJSON `json:"data_points"` Bars []histPointJSON `json:"bars"` } func parseHistoricals(raw json.RawMessage) ([]Bar, error) { // Live Agentic wire (CT113): {"data":{"results":[{"symbol","bars":[{open_price,...}]}]}}. // Fixture / older shapes: {"historicals":[{"symbol","data_points":[{open,...}]}]}. rows, _, err := wire.UnmarshalRows[histSeriesJSON](raw, "results", "historicals") if err != nil { return nil, err } var out []Bar for _, series := range rows { if series.Symbol == "" { continue } points := series.Bars if len(points) == 0 { points = series.DataPoints } for _, p := range points { ts, err := time.Parse(time.RFC3339, p.BeginsAt) if err != nil { continue } o, err := firstDec(p.OpenPrice, p.Open) if err != nil { return nil, err } h, err := firstDec(p.HighPrice, p.High) if err != nil { return nil, err } l, err := firstDec(p.LowPrice, p.Low) if err != nil { return nil, err } cl, err := firstDec(p.ClosePrice, p.Close) if err != nil { return nil, err } vol, err := firstDec(p.Volume) if err != nil { return nil, err } out = append(out, Bar{ Symbol: series.Symbol, Time: ts, Open: o, High: h, Low: l, Close: cl, Volume: vol, Interpolated: p.Interpolated, }) } } return out, 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 }