Files
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

271 lines
6.9 KiB
Go

package options
import (
"context"
decimal "github.com/alpacahq/alpacadecimal"
"s1d3sw1ped/robinhood-agentic-mcp/client"
"s1d3sw1ped/robinhood-agentic-mcp/internal/wire"
)
const (
toolReview = "review_option_order"
toolPlace = "place_option_order"
toolCancel = "cancel_option_order"
toolReplace = "replace_option_order"
toolExercise = "exercise_option"
toolCancelExercise = "cancel_option_exercise"
)
// Leg is one contract in an option order.
type Leg struct {
OptionID string
Side client.Side
PositionEffect string // "open" | "close"
RatioQuantity int
}
// PlaceOrderRequest is the argument set for review_option_order and place_option_order.
type PlaceOrderRequest struct {
AccountNumber string
Legs []Leg
Direction string
Type client.OrderType
Quantity *decimal.Decimal
Price *decimal.Decimal
StopPrice *decimal.Decimal
TimeInForce client.TimeInForce // empty → omit (Robinhood defaults gfd)
MarketHours client.MarketHours
RefID string
ChainSymbol string // review only
UnderlyingType string // review only
}
// ReplaceOrderRequest is the argument set for replace_option_order.
type ReplaceOrderRequest struct {
AccountNumber string
OrderID string
Legs []Leg
Direction string
Type client.OrderType
Quantity *decimal.Decimal
Price *decimal.Decimal
StopPrice *decimal.Decimal
TimeInForce client.TimeInForce
MarketHours client.MarketHours
RefID string
}
// ReviewResult is the pre-trade check from review_option_order.
type ReviewResult struct {
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
Alerts []string `json:"alerts"`
}
// Order is a placed, replaced, or listed option order.
type Order struct {
ID string `json:"id"`
State string `json:"state"`
Qty decimal.Decimal `json:"-"`
Price decimal.Decimal `json:"-"`
}
// CancelOrderRequest is the argument set for cancel_option_order.
type CancelOrderRequest struct {
AccountNumber string
OrderID string
}
// ExerciseRequest is the argument set for exercise_option.
type ExerciseRequest struct {
AccountNumber string
OptionID string
Quantity int
RefID string
Reason string
AllowShorts bool
}
// ExerciseResult is the parsed exercise_option payload.
type ExerciseResult struct {
ID string `json:"id"`
State string `json:"state"`
}
// CancelExerciseRequest is the argument set for cancel_option_exercise.
type CancelExerciseRequest struct {
AccountNumber string
OptionID string
}
// ReviewOrder calls review_option_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, true), &out); err != nil {
return ReviewResult{}, err
}
return out, nil
}
// PlaceOrder calls place_option_order. RefID is sent as ref_id.
func (c *Client) PlaceOrder(ctx context.Context, req PlaceOrderRequest) (Order, error) {
raw, err := c.c.Call(ctx, toolPlace, placeArgs(req, true, false))
if err != nil {
return Order{}, err
}
ords, err := parseOrders(raw)
if err != nil {
return Order{}, client.ToolErrorf(toolPlace, "parse: %w", err)
}
if len(ords) > 0 {
return ords[0], nil
}
return Order{}, nil
}
// CancelOrder calls cancel_option_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
}
// ReplaceOrder calls replace_option_order.
func (c *Client) ReplaceOrder(ctx context.Context, req ReplaceOrderRequest) (Order, error) {
args := placeArgs(PlaceOrderRequest{
AccountNumber: req.AccountNumber,
Legs: req.Legs,
Direction: req.Direction,
Type: req.Type,
Quantity: req.Quantity,
Price: req.Price,
StopPrice: req.StopPrice,
TimeInForce: req.TimeInForce,
MarketHours: req.MarketHours,
RefID: req.RefID,
}, true, false)
if req.OrderID != "" {
args["order_id"] = req.OrderID
}
raw, err := c.c.Call(ctx, toolReplace, args)
if err != nil {
return Order{}, err
}
ords, err := parseOrders(raw)
if err != nil {
return Order{}, client.ToolErrorf(toolReplace, "parse: %w", err)
}
if len(ords) > 0 {
return ords[0], nil
}
return Order{}, nil
}
// Exercise calls exercise_option.
func (c *Client) Exercise(ctx context.Context, req ExerciseRequest) (ExerciseResult, error) {
args := map[string]any{}
if req.AccountNumber != "" {
args["account_number"] = req.AccountNumber
}
if req.OptionID != "" {
args["option_id"] = req.OptionID
}
if req.Quantity != 0 {
args["quantity"] = req.Quantity
}
if req.RefID != "" {
args["ref_id"] = req.RefID
}
if req.Reason != "" {
args["reason"] = req.Reason
}
if req.AllowShorts {
args["allow_shorts"] = true
}
var out ExerciseResult
if err := c.parse(ctx, toolExercise, args, &out); err != nil {
return ExerciseResult{}, err
}
return out, nil
}
// CancelExercise calls cancel_option_exercise.
func (c *Client) CancelExercise(ctx context.Context, req CancelExerciseRequest) error {
args := map[string]any{}
if req.AccountNumber != "" {
args["account_number"] = req.AccountNumber
}
if req.OptionID != "" {
args["option_id"] = req.OptionID
}
_, err := c.c.Call(ctx, toolCancelExercise, args)
return err
}
func placeArgs(req PlaceOrderRequest, withRef, review bool) map[string]any {
args := map[string]any{}
if req.AccountNumber != "" {
args["account_number"] = req.AccountNumber
}
if len(req.Legs) > 0 {
legs := make([]map[string]any, len(req.Legs))
for i, leg := range req.Legs {
m := map[string]any{}
if leg.OptionID != "" {
m["option_id"] = leg.OptionID
}
if leg.Side != "" {
m["side"] = string(leg.Side)
}
if leg.PositionEffect != "" {
m["position_effect"] = leg.PositionEffect
}
if leg.RatioQuantity != 0 {
m["ratio_quantity"] = leg.RatioQuantity
}
legs[i] = m
}
args["legs"] = legs
}
if req.Direction != "" {
args["direction"] = req.Direction
}
if req.Type != "" {
args["type"] = string(req.Type)
}
if req.Quantity != nil {
args["quantity"] = wire.Encode(*req.Quantity)
}
if req.Price != nil {
args["price"] = wire.Encode(*req.Price)
}
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 withRef && req.RefID != "" {
args["ref_id"] = req.RefID
}
if review {
if req.ChainSymbol != "" {
args["chain_symbol"] = req.ChainSymbol
}
if req.UnderlyingType != "" {
args["underlying_type"] = req.UnderlyingType
}
}
return args
}