Files
robinhood-agentic-mcp/equity/write.go
T
s1d3sw1ped a6bf8632ce 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.
2026-09-01 14:18:39 -05:00

209 lines
5.4 KiB
Go

package equity
import (
"context"
"encoding/json"
decimal "github.com/alpacahq/alpacadecimal"
"s1d3sw1ped/robinhood-agentic-mcp/client"
"s1d3sw1ped/robinhood-agentic-mcp/internal/wire"
)
const (
toolReview = "review_equity_order"
toolPlace = "place_equity_order"
toolCancel = "cancel_equity_order"
)
// PlaceOrderRequest is the argument set for review_equity_order and place_equity_order.
type PlaceOrderRequest struct {
AccountNumber string
Symbol string
Side client.Side
Type client.OrderType
Qty *decimal.Decimal
DollarAmount *decimal.Decimal
LimitPrice *decimal.Decimal
StopPrice *decimal.Decimal
TimeInForce client.TimeInForce // empty → omit (Robinhood defaults gfd)
MarketHours client.MarketHours
TaxLots []TaxLot
RefID string
}
// TaxLot is a specified-lot selection for a sell order.
type TaxLot struct {
OpenLotID string
Quantity decimal.Decimal
}
// ReviewResult is the pre-trade check from review_equity_order.
type ReviewResult struct {
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
}
// Order is a placed or listed equity order.
type Order struct {
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.
type CancelOrderRequest struct {
AccountNumber string
OrderID string
}
// ReviewOrder calls review_equity_order. RefID is not sent.
func (c *Client) ReviewOrder(ctx context.Context, req PlaceOrderRequest) (ReviewResult, error) {
var out ReviewResult
if err := c.parse(ctx, toolReview, placeArgs(req, false), &out); err != nil {
return ReviewResult{}, err
}
return out, nil
}
// PlaceOrder calls place_equity_order. RefID is sent as ref_id only.
func (c *Client) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (Order, error) {
raw, err := c.c.Call(ctx, toolPlace, placeArgs(req, true))
if err != nil {
return Order{}, err
}
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.
func (c *Client) CancelOrder(ctx context.Context, req CancelOrderRequest) error {
args := map[string]any{}
if req.AccountNumber != "" {
args["account_number"] = req.AccountNumber
}
if req.OrderID != "" {
args["order_id"] = req.OrderID
}
_, err := c.c.Call(ctx, toolCancel, args)
return err
}
func placeArgs(req PlaceOrderRequest, withRef bool) map[string]any {
args := map[string]any{}
if req.AccountNumber != "" {
args["account_number"] = req.AccountNumber
}
if req.Symbol != "" {
args["symbol"] = req.Symbol
}
if req.Side != "" {
args["side"] = string(req.Side)
}
if req.Type != "" {
args["type"] = string(req.Type)
}
if req.Qty != nil {
args["quantity"] = wire.Encode(*req.Qty)
}
if req.DollarAmount != nil {
args["dollar_amount"] = wire.Encode(*req.DollarAmount)
}
if req.LimitPrice != nil {
args["limit_price"] = wire.Encode(*req.LimitPrice)
}
if req.StopPrice != nil {
args["stop_price"] = wire.Encode(*req.StopPrice)
}
if req.TimeInForce != "" {
args["time_in_force"] = string(req.TimeInForce)
}
if req.MarketHours != "" {
args["market_hours"] = string(req.MarketHours)
}
if len(req.TaxLots) > 0 {
lots := make([]map[string]any, len(req.TaxLots))
for i, lot := range req.TaxLots {
lots[i] = map[string]any{
"open_lot_id": lot.OpenLotID,
"quantity": wire.Encode(lot.Quantity),
}
}
args["tax_lots"] = lots
}
if withRef && req.RefID != "" {
args["ref_id"] = req.RefID
}
return args
}