feat: add equity place, review, and cancel
This commit is contained in:
@@ -25,5 +25,8 @@ func Tools() []string {
|
|||||||
toolPriceBook,
|
toolPriceBook,
|
||||||
toolTechnicalIndicators,
|
toolTechnicalIndicators,
|
||||||
toolNews,
|
toolNews,
|
||||||
|
toolReview,
|
||||||
|
toolPlace,
|
||||||
|
toolCancel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ func TestEquity_toolNames(t *testing.T) {
|
|||||||
func TestTools(t *testing.T) {
|
func TestTools(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
want := []string{
|
want := []string{
|
||||||
|
"cancel_equity_order",
|
||||||
"get_equity_fundamentals",
|
"get_equity_fundamentals",
|
||||||
"get_equity_historicals",
|
"get_equity_historicals",
|
||||||
"get_equity_news",
|
"get_equity_news",
|
||||||
@@ -235,6 +236,8 @@ func TestTools(t *testing.T) {
|
|||||||
"get_equity_tax_lots",
|
"get_equity_tax_lots",
|
||||||
"get_equity_technical_indicators",
|
"get_equity_technical_indicators",
|
||||||
"get_equity_tradability",
|
"get_equity_tradability",
|
||||||
|
"place_equity_order",
|
||||||
|
"review_equity_order",
|
||||||
}
|
}
|
||||||
got := append([]string(nil), equity.Tools()...)
|
got := append([]string(nil), equity.Tools()...)
|
||||||
sort.Strings(got)
|
sort.Strings(got)
|
||||||
|
|||||||
+134
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user