feat: add options MCP methods
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
package options_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
decimal "github.com/alpacahq/alpacadecimal"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"s1d3sw1ped/robinhood-agentic-mcp/client"
|
||||
"s1d3sw1ped/robinhood-agentic-mcp/internal/rhntest"
|
||||
"s1d3sw1ped/robinhood-agentic-mcp/options"
|
||||
)
|
||||
|
||||
func TestOptions_toolNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
qty := decimal.NewFromInt(1)
|
||||
px := decimal.RequireFromString("1.5")
|
||||
stop := decimal.RequireFromString("1.4")
|
||||
start := time.Date(2026, 8, 18, 13, 30, 0, 0, time.UTC)
|
||||
end := time.Date(2026, 8, 18, 20, 0, 0, 0, time.UTC)
|
||||
leg := options.Leg{
|
||||
OptionID: "opt-1",
|
||||
Side: client.Buy,
|
||||
PositionEffect: "open",
|
||||
RatioQuantity: 1,
|
||||
}
|
||||
legsWire := []map[string]any{
|
||||
{"option_id": "opt-1", "side": "buy", "position_effect": "open", "ratio_quantity": 1},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
call func(*options.Client) error
|
||||
wantName string
|
||||
wantArgs map[string]any
|
||||
}{
|
||||
{
|
||||
name: "Chains",
|
||||
call: func(c *options.Client) error {
|
||||
_, err := c.Chains(context.Background(), options.ChainsRequest{
|
||||
IDs: "chain-1",
|
||||
UnderlyingSymbol: "AAPL",
|
||||
})
|
||||
return err
|
||||
},
|
||||
wantName: "get_option_chains",
|
||||
wantArgs: map[string]any{"ids": "chain-1", "underlying_symbol": "AAPL"},
|
||||
},
|
||||
{
|
||||
name: "Instruments",
|
||||
call: func(c *options.Client) error {
|
||||
_, err := c.Instruments(context.Background(), options.InstrumentsRequest{
|
||||
ChainID: "chain-1",
|
||||
ChainSymbol: "AAPL",
|
||||
ExpirationDates: "2026-09-18",
|
||||
StrikePrice: "150.0000",
|
||||
Type: "call",
|
||||
State: "active",
|
||||
Tradability: "tradable",
|
||||
IDs: "opt-1",
|
||||
Cursor: "c1",
|
||||
})
|
||||
return err
|
||||
},
|
||||
wantName: "get_option_instruments",
|
||||
wantArgs: map[string]any{
|
||||
"chain_id": "chain-1",
|
||||
"chain_symbol": "AAPL",
|
||||
"expiration_dates": "2026-09-18",
|
||||
"strike_price": "150.0000",
|
||||
"type": "call",
|
||||
"state": "active",
|
||||
"tradability": "tradable",
|
||||
"ids": "opt-1",
|
||||
"cursor": "c1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Quotes",
|
||||
call: func(c *options.Client) error {
|
||||
_, err := c.Quotes(context.Background(), options.QuotesRequest{
|
||||
InstrumentIDs: []string{"opt-1", "opt-2"},
|
||||
})
|
||||
return err
|
||||
},
|
||||
wantName: "get_option_quotes",
|
||||
wantArgs: map[string]any{"instrument_ids": []string{"opt-1", "opt-2"}},
|
||||
},
|
||||
{
|
||||
name: "Positions",
|
||||
call: func(c *options.Client) error {
|
||||
_, err := c.Positions(context.Background(), options.PositionsRequest{
|
||||
AccountNumber: "acct-1",
|
||||
Nonzero: true,
|
||||
ChainIDs: "chain-1",
|
||||
OptionIDs: "opt-1",
|
||||
Type: "long",
|
||||
OptionType: "call",
|
||||
ExpirationDate: "2026-09-18",
|
||||
ExpirationDateLTE: "2026-12-31",
|
||||
ExpirationDateGTE: "2026-01-01",
|
||||
Cursor: "c1",
|
||||
})
|
||||
return err
|
||||
},
|
||||
wantName: "get_option_positions",
|
||||
wantArgs: map[string]any{
|
||||
"account_number": "acct-1",
|
||||
"nonzero": true,
|
||||
"chain_ids": "chain-1",
|
||||
"option_ids": "opt-1",
|
||||
"type": "long",
|
||||
"option_type": "call",
|
||||
"expiration_date": "2026-09-18",
|
||||
"expiration_date_lte": "2026-12-31",
|
||||
"expiration_date_gte": "2026-01-01",
|
||||
"cursor": "c1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Orders",
|
||||
call: func(c *options.Client) error {
|
||||
_, err := c.Orders(context.Background(), options.OrdersRequest{
|
||||
AccountNumber: "acct-1",
|
||||
OrderID: "o1",
|
||||
State: "filled",
|
||||
CreatedAtGTE: "2026-08-18",
|
||||
ChainIDs: "chain-1",
|
||||
UnderlyingType: "equity",
|
||||
PlacedAgent: "agentic",
|
||||
Cursor: "c1",
|
||||
})
|
||||
return err
|
||||
},
|
||||
wantName: "get_option_orders",
|
||||
wantArgs: map[string]any{
|
||||
"account_number": "acct-1",
|
||||
"order_id": "o1",
|
||||
"state": "filled",
|
||||
"created_at_gte": "2026-08-18",
|
||||
"chain_ids": "chain-1",
|
||||
"underlying_type": "equity",
|
||||
"placed_agent": "agentic",
|
||||
"cursor": "c1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Historicals",
|
||||
call: func(c *options.Client) error {
|
||||
_, err := c.Historicals(context.Background(), options.HistoricalsRequest{
|
||||
InstrumentIDs: []string{"opt-1"},
|
||||
StartTime: start,
|
||||
EndTime: end,
|
||||
Interval: "minute",
|
||||
Bounds: "regular",
|
||||
})
|
||||
return err
|
||||
},
|
||||
wantName: "get_option_historicals",
|
||||
wantArgs: map[string]any{
|
||||
"instrument_ids": []string{"opt-1"},
|
||||
"start_time": "2026-08-18T13:30:00Z",
|
||||
"end_time": "2026-08-18T20:00:00Z",
|
||||
"interval": "minute",
|
||||
"bounds": "regular",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ReviewOrder",
|
||||
call: func(c *options.Client) error {
|
||||
_, err := c.ReviewOrder(context.Background(), options.PlaceOrderRequest{
|
||||
AccountNumber: "acct",
|
||||
Legs: []options.Leg{leg},
|
||||
Type: client.Limit,
|
||||
Quantity: &qty,
|
||||
Price: &px,
|
||||
TimeInForce: client.GFD,
|
||||
MarketHours: client.RegularHours,
|
||||
ChainSymbol: "AAPL",
|
||||
UnderlyingType: "equity",
|
||||
RefID: "buy:opt",
|
||||
})
|
||||
return err
|
||||
},
|
||||
wantName: "review_option_order",
|
||||
wantArgs: map[string]any{
|
||||
"account_number": "acct",
|
||||
"legs": legsWire,
|
||||
"type": "limit",
|
||||
"quantity": "1",
|
||||
"price": "1.5",
|
||||
"time_in_force": "gfd",
|
||||
"market_hours": "regular_hours",
|
||||
"chain_symbol": "AAPL",
|
||||
"underlying_type": "equity",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PlaceOrder",
|
||||
call: func(c *options.Client) error {
|
||||
_, err := c.PlaceOrder(context.Background(), options.PlaceOrderRequest{
|
||||
AccountNumber: "acct",
|
||||
Legs: []options.Leg{leg},
|
||||
Direction: "debit",
|
||||
Type: client.StopLimit,
|
||||
Quantity: &qty,
|
||||
Price: &px,
|
||||
StopPrice: &stop,
|
||||
TimeInForce: client.GTC,
|
||||
MarketHours: client.RegularCurbHours,
|
||||
RefID: "buy:opt",
|
||||
})
|
||||
return err
|
||||
},
|
||||
wantName: "place_option_order",
|
||||
wantArgs: map[string]any{
|
||||
"account_number": "acct",
|
||||
"legs": legsWire,
|
||||
"direction": "debit",
|
||||
"type": "stop_limit",
|
||||
"quantity": "1",
|
||||
"price": "1.5",
|
||||
"stop_price": "1.4",
|
||||
"time_in_force": "gtc",
|
||||
"market_hours": "regular_curb_hours",
|
||||
"ref_id": "buy:opt",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CancelOrder",
|
||||
call: func(c *options.Client) error {
|
||||
return c.CancelOrder(context.Background(), options.CancelOrderRequest{
|
||||
AccountNumber: "acct",
|
||||
OrderID: "o1",
|
||||
})
|
||||
},
|
||||
wantName: "cancel_option_order",
|
||||
wantArgs: map[string]any{"account_number": "acct", "order_id": "o1"},
|
||||
},
|
||||
{
|
||||
name: "ReplaceOrder",
|
||||
call: func(c *options.Client) error {
|
||||
_, err := c.ReplaceOrder(context.Background(), options.ReplaceOrderRequest{
|
||||
AccountNumber: "acct",
|
||||
OrderID: "o1",
|
||||
Legs: []options.Leg{leg},
|
||||
Type: client.Limit,
|
||||
Quantity: &qty,
|
||||
Price: &px,
|
||||
RefID: "repl:opt",
|
||||
})
|
||||
return err
|
||||
},
|
||||
wantName: "replace_option_order",
|
||||
wantArgs: map[string]any{
|
||||
"account_number": "acct",
|
||||
"order_id": "o1",
|
||||
"legs": legsWire,
|
||||
"type": "limit",
|
||||
"quantity": "1",
|
||||
"price": "1.5",
|
||||
"ref_id": "repl:opt",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Exercise",
|
||||
call: func(c *options.Client) error {
|
||||
_, err := c.Exercise(context.Background(), options.ExerciseRequest{
|
||||
AccountNumber: "acct",
|
||||
OptionID: "opt-1",
|
||||
Quantity: 2,
|
||||
RefID: "ex:opt",
|
||||
Reason: "buying_stocks",
|
||||
AllowShorts: true,
|
||||
})
|
||||
return err
|
||||
},
|
||||
wantName: "exercise_option",
|
||||
wantArgs: map[string]any{
|
||||
"account_number": "acct",
|
||||
"option_id": "opt-1",
|
||||
"quantity": 2,
|
||||
"ref_id": "ex:opt",
|
||||
"reason": "buying_stocks",
|
||||
"allow_shorts": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "CancelExercise",
|
||||
call: func(c *options.Client) error {
|
||||
return c.CancelExercise(context.Background(), options.CancelExerciseRequest{
|
||||
AccountNumber: "acct",
|
||||
OptionID: "opt-1",
|
||||
})
|
||||
},
|
||||
wantName: "cancel_option_exercise",
|
||||
wantArgs: map[string]any{"account_number": "acct", "option_id": "opt-1"},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var gotName string
|
||||
var gotArgs map[string]any
|
||||
c := options.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 tc.name == "PlaceOrder" {
|
||||
if _, ok := gotArgs["idempotency_key"]; ok {
|
||||
t.Fatalf("idempotency_key on place: %+v", gotArgs)
|
||||
}
|
||||
if _, ok := gotArgs["chain_symbol"]; ok {
|
||||
t.Fatalf("chain_symbol on place: %+v", gotArgs)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTools(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := []string{
|
||||
"cancel_option_exercise",
|
||||
"cancel_option_order",
|
||||
"exercise_option",
|
||||
"get_option_chains",
|
||||
"get_option_historicals",
|
||||
"get_option_instruments",
|
||||
"get_option_orders",
|
||||
"get_option_positions",
|
||||
"get_option_quotes",
|
||||
"place_option_order",
|
||||
"replace_option_order",
|
||||
"review_option_order",
|
||||
}
|
||||
got := append([]string(nil), options.Tools()...)
|
||||
sort.Strings(got)
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Fatal(diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotes_rhntest(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := rhntest.New(t)
|
||||
s.Set("get_option_quotes", json.RawMessage(`{}`))
|
||||
c := options.New(&client.Client{URL: s.URL})
|
||||
_, err := c.Quotes(context.Background(), options.QuotesRequest{
|
||||
InstrumentIDs: []string{"opt-1", "opt-2"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.LastName() != "get_option_quotes" {
|
||||
t.Fatalf("%s", s.LastName())
|
||||
}
|
||||
want := map[string]any{"instrument_ids": []any{"opt-1", "opt-2"}}
|
||||
if diff := cmp.Diff(want, s.LastArgs()); diff != "" {
|
||||
t.Fatal(diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceOrder_rhntest(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := rhntest.New(t)
|
||||
s.Set("place_option_order", json.RawMessage(`{"id":"o1"}`))
|
||||
c := options.New(&client.Client{URL: s.URL})
|
||||
qty := decimal.NewFromInt(1)
|
||||
px := decimal.RequireFromString("1.5")
|
||||
got, err := c.PlaceOrder(context.Background(), options.PlaceOrderRequest{
|
||||
AccountNumber: "acct",
|
||||
Legs: []options.Leg{{
|
||||
OptionID: "opt-1",
|
||||
Side: client.Buy,
|
||||
PositionEffect: "open",
|
||||
RatioQuantity: 1,
|
||||
}},
|
||||
Type: client.Limit,
|
||||
Quantity: &qty,
|
||||
Price: &px,
|
||||
RefID: "buy:opt",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != "o1" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if s.LastName() != "place_option_order" {
|
||||
t.Fatalf("%s", s.LastName())
|
||||
}
|
||||
wantLegs := []any{
|
||||
map[string]any{
|
||||
"option_id": "opt-1",
|
||||
"side": "buy",
|
||||
"position_effect": "open",
|
||||
"ratio_quantity": float64(1),
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(wantLegs, s.LastArgs()["legs"]); diff != "" {
|
||||
t.Fatal(diff)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user