diff --git a/equity/client.go b/equity/client.go index 58e07b3..2f2857b 100644 --- a/equity/client.go +++ b/equity/client.go @@ -25,5 +25,8 @@ func Tools() []string { toolPriceBook, toolTechnicalIndicators, toolNews, + toolReview, + toolPlace, + toolCancel, } } diff --git a/equity/read_test.go b/equity/read_test.go index 8416c1a..4dbf8a4 100644 --- a/equity/read_test.go +++ b/equity/read_test.go @@ -225,6 +225,7 @@ func TestEquity_toolNames(t *testing.T) { func TestTools(t *testing.T) { t.Parallel() want := []string{ + "cancel_equity_order", "get_equity_fundamentals", "get_equity_historicals", "get_equity_news", @@ -235,6 +236,8 @@ func TestTools(t *testing.T) { "get_equity_tax_lots", "get_equity_technical_indicators", "get_equity_tradability", + "place_equity_order", + "review_equity_order", } got := append([]string(nil), equity.Tools()...) sort.Strings(got) diff --git a/equity/write.go b/equity/write.go new file mode 100644 index 0000000..d61876a --- /dev/null +++ b/equity/write.go @@ -0,0 +1,134 @@ +package equity + +import ( + "context" + + 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 equity order. +type Order struct { + ID string `json:"id"` +} + +// 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 both ref_id and idempotency_key. +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 { + return Order{}, err + } + return out, 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 + args["idempotency_key"] = req.RefID + } + return args +} diff --git a/equity/write_test.go b/equity/write_test.go new file mode 100644 index 0000000..f888367 --- /dev/null +++ b/equity/write_test.go @@ -0,0 +1,252 @@ +package equity_test + +import ( + "context" + "encoding/json" + "testing" + + decimal "github.com/alpacahq/alpacadecimal" + "github.com/google/go-cmp/cmp" + "s1d3sw1ped/robinhood-agentic-mcp/client" + "s1d3sw1ped/robinhood-agentic-mcp/equity" + "s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest" +) + +func TestPlaceOrder_decimalStrings(t *testing.T) { + t.Parallel() + var got map[string]any + c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + if name != "place_equity_order" { + t.Fatalf("%s", name) + } + got = args + return json.RawMessage(`{"id":"o1"}`), nil + })) + qty := decimal.NewFromInt(3) + px := decimal.RequireFromString("99.6") + _, err := c.PlaceOrder(context.Background(), equity.PlaceOrderRequest{ + AccountNumber: "acct", Symbol: "MU", Side: client.Buy, Type: client.Limit, + Qty: &qty, LimitPrice: &px, TimeInForce: client.GFD, RefID: "buy:2026-08-18:MU", + }) + if err != nil { + t.Fatal(err) + } + if got["type"] != "limit" || got["time_in_force"] != "gfd" || got["quantity"] != "3" { + t.Fatalf("%+v", got) + } + if got["limit_price"] != "99.6" && got["limit_price"] != "99.60" { + t.Fatalf("limit %v", got["limit_price"]) + } + if got["ref_id"] != "buy:2026-08-18:MU" { + t.Fatalf("ref %v", got["ref_id"]) + } +} + +func TestEquity_writeArgs(t *testing.T) { + t.Parallel() + qty := decimal.NewFromInt(3) + px := decimal.RequireFromString("99.6") + dollars := decimal.RequireFromString("100") + lotQty := decimal.RequireFromString("1.5") + tests := []struct { + name string + call func(*equity.Client) error + wantName string + wantArgs map[string]any + }{ + { + name: "ReviewOrder", + call: func(c *equity.Client) error { + _, err := c.ReviewOrder(context.Background(), equity.PlaceOrderRequest{ + AccountNumber: "acct", + Symbol: "MU", + Side: client.Buy, + Type: client.Limit, + Qty: &qty, + LimitPrice: &px, + TimeInForce: client.GFD, + MarketHours: client.ExtendedHours, + RefID: "buy:2026-08-18:MU", + }) + return err + }, + wantName: "review_equity_order", + wantArgs: map[string]any{ + "account_number": "acct", + "symbol": "MU", + "side": "buy", + "type": "limit", + "quantity": "3", + "limit_price": "99.6", + "time_in_force": "gfd", + "market_hours": "extended_hours", + }, + }, + { + name: "PlaceOrderStop", + call: func(c *equity.Client) error { + _, err := c.PlaceOrder(context.Background(), equity.PlaceOrderRequest{ + AccountNumber: "acct", + Symbol: "MU", + Side: client.Sell, + Type: client.Stop, + Qty: &qty, + StopPrice: &px, + RefID: "sell:2026-08-18:MU", + }) + return err + }, + 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", + }, + }, + { + name: "PlaceOrderDollarAndLots", + call: func(c *equity.Client) error { + _, err := c.PlaceOrder(context.Background(), equity.PlaceOrderRequest{ + AccountNumber: "acct", + Symbol: "MU", + Side: client.Sell, + Type: client.Market, + DollarAmount: &dollars, + TaxLots: []equity.TaxLot{{ + OpenLotID: "lot-1", + Quantity: lotQty, + }}, + RefID: "sell:lots", + }) + return err + }, + wantName: "place_equity_order", + wantArgs: map[string]any{ + "account_number": "acct", + "symbol": "MU", + "side": "sell", + "type": "market", + "dollar_amount": "100", + "tax_lots": []map[string]any{ + {"open_lot_id": "lot-1", "quantity": "1.5"}, + }, + "ref_id": "sell:lots", + "idempotency_key": "sell:lots", + }, + }, + { + name: "CancelOrder", + call: func(c *equity.Client) error { + return c.CancelOrder(context.Background(), equity.CancelOrderRequest{ + AccountNumber: "acct", + OrderID: "o1", + }) + }, + wantName: "cancel_equity_order", + wantArgs: map[string]any{"account_number": "acct", "order_id": "o1"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var gotName string + var gotArgs map[string]any + c := equity.New(client.Func(func(ctx context.Context, name string, args map[string]any) (json.RawMessage, error) { + gotName, gotArgs = name, args + return json.RawMessage(`{"id":"o1"}`), nil + })) + if err := tc.call(c); err != nil { + t.Fatal(err) + } + if gotName != tc.wantName { + t.Fatalf("%s %+v", gotName, gotArgs) + } + if diff := cmp.Diff(tc.wantArgs, gotArgs); diff != "" { + t.Fatal(diff) + } + if tc.name == "ReviewOrder" { + if _, ok := gotArgs["ref_id"]; ok { + t.Fatalf("ref_id on review: %+v", gotArgs) + } + if _, ok := gotArgs["idempotency_key"]; ok { + t.Fatalf("idempotency_key on review: %+v", gotArgs) + } + } + if tc.name == "PlaceOrderStop" { + if _, ok := gotArgs["time_in_force"]; ok { + t.Fatalf("time_in_force injected: %+v", gotArgs) + } + } + }) + } +} + +func TestPlaceOrder_rhntest(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.Set("place_equity_order", json.RawMessage(`{"id":"o1"}`)) + c := equity.New(&client.Client{URL: s.URL}) + qty := decimal.NewFromInt(3) + px := decimal.RequireFromString("99.6") + got, err := c.PlaceOrder(context.Background(), equity.PlaceOrderRequest{ + AccountNumber: "acct", Symbol: "MU", Side: client.Buy, Type: client.Limit, + Qty: &qty, LimitPrice: &px, TimeInForce: client.GFD, RefID: "buy:2026-08-18:MU", + }) + if err != nil { + t.Fatal(err) + } + if got.ID != "o1" { + t.Fatalf("%+v", got) + } + if s.LastName() != "place_equity_order" { + t.Fatalf("%s", s.LastName()) + } +} + +func TestReviewOrder_rhntest(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.Set("review_equity_order", json.RawMessage(`{"data":{"errors":["insufficient buying power"],"warnings":["PDT"]}}`)) + c := equity.New(&client.Client{URL: s.URL}) + qty := decimal.NewFromInt(3) + got, err := c.ReviewOrder(context.Background(), equity.PlaceOrderRequest{ + AccountNumber: "acct", Symbol: "MU", Side: client.Buy, Type: client.Market, Qty: &qty, + }) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff([]string{"insufficient buying power"}, got.Errors); diff != "" { + t.Fatal(diff) + } + if diff := cmp.Diff([]string{"PDT"}, got.Warnings); diff != "" { + t.Fatal(diff) + } + if s.LastName() != "review_equity_order" { + t.Fatalf("%s", s.LastName()) + } +} + +func TestCancelOrder_rhntest(t *testing.T) { + t.Parallel() + s := rhntest.New(t) + s.Set("cancel_equity_order", json.RawMessage(`{}`)) + c := equity.New(&client.Client{URL: s.URL}) + if err := c.CancelOrder(context.Background(), equity.CancelOrderRequest{ + AccountNumber: "acct", + OrderID: "o1", + }); err != nil { + t.Fatal(err) + } + if s.LastName() != "cancel_equity_order" { + t.Fatalf("%s", s.LastName()) + } + want := map[string]any{"account_number": "acct", "order_id": "o1"} + if diff := cmp.Diff(want, s.LastArgs()); diff != "" { + t.Fatal(diff) + } +}