feat: add options trading — Alpaca Level 3 multi-leg support

- OptionsProvider interface (7 methods): chain, expirations, quote,
  single-leg orders, multi-leg orders, exercise, positions
- Alpaca implementation: order_class "mleg", ratio_qty, position_intent
- Fix 3 critical bugs: mleg class, ratio_qty field, position_intent
- Add DoNotExercise + SetOptionsApprovalLevel endpoints
- HTTP handlers: 10 new routes under /v1/options/*
- Security: OCC symbol validation, input sanitization, audit logging
- Tests: 16 provider + handler tests passing
This commit is contained in:
2026-03-30 15:25:47 -07:00
parent 760d8e710e
commit 5de9cbe333
11 changed files with 2735 additions and 25 deletions
+27 -17
View File
@@ -100,18 +100,24 @@ func main() {
srv.Mount("/compliance", compliance.NewRouter(complianceStore, adminStore))
log.Info().Msg("Compliance routes mounted at /compliance")
// --- gRPC Server ---
grpcAddr := envOr("BROKER_GRPC_LISTEN", ":9090")
grpcSrv, err := brokergrpc.NewServer(brokergrpc.Config{
ListenAddr: grpcAddr,
Registry: registry,
Router: sor,
TWAPScheduler: twapScheduler,
Feed: feed,
ArbitrageDetector: arbDetector,
})
if err != nil {
log.Fatal().Err(err).Msg("Failed to create gRPC server")
// --- gRPC Server (optional) ---
grpcAddr := os.Getenv("BROKER_GRPC_LISTEN")
var grpcSrv *brokergrpc.Server
if grpcAddr != "" {
var err error
grpcSrv, err = brokergrpc.NewServer(brokergrpc.Config{
ListenAddr: grpcAddr,
Registry: registry,
Router: sor,
TWAPScheduler: twapScheduler,
Feed: feed,
ArbitrageDetector: arbDetector,
})
if err != nil {
log.Fatal().Err(err).Msg("Failed to create gRPC server")
}
} else {
log.Info().Msg("gRPC server disabled (set BROKER_GRPC_LISTEN to enable)")
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
@@ -122,15 +128,19 @@ func main() {
log.Info().Str("addr", listenAddr).Strs("providers", registry.List()).Msg("Broker REST API starting")
errCh <- srv.Start()
}()
go func() {
log.Info().Str("addr", grpcAddr).Msg("Broker gRPC API starting")
errCh <- grpcSrv.Serve()
}()
if grpcSrv != nil {
go func() {
log.Info().Str("addr", grpcAddr).Msg("Broker gRPC API starting")
errCh <- grpcSrv.Serve()
}()
}
select {
case <-ctx.Done():
log.Info().Msg("Shutting down...")
grpcSrv.Stop()
if grpcSrv != nil {
grpcSrv.Stop()
}
if err := srv.Shutdown(context.Background()); err != nil {
log.Error().Err(err).Msg("REST shutdown error")
}
+682
View File
@@ -0,0 +1,682 @@
package api
import (
"encoding/json"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/luxfi/broker/pkg/audit"
"github.com/luxfi/broker/pkg/provider"
"github.com/luxfi/broker/pkg/types"
)
// --- Input validation ---
// occSymbolRe validates OCC option symbol format: ROOT(1-6) + DATE(6) + TYPE(1) + STRIKE(8).
// Examples: AAPL260418C00150000, SPY260620P00500000, X261231C00001000
var occSymbolRe = regexp.MustCompile(`^[A-Z]{1,6}\d{6}[CP]\d{8}$`)
// underlyingSymbolRe validates underlying equity symbols: 1-6 uppercase letters.
var underlyingSymbolRe = regexp.MustCompile(`^[A-Z]{1,6}$`)
// expirationRe validates YYYY-MM-DD date format.
var expirationRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
// maxMultiLegLegs caps multi-leg orders. Alpaca supports up to 4 legs (Level 3).
// Iron condor is 4 legs; anything beyond that is invalid.
const maxMultiLegLegs = 4
// maxOptionOrderQty prevents absurd quantity values that indicate API abuse.
const maxOptionOrderQty = 10000
// maxRequestBodyBytes limits request body size for options endpoints (64 KB).
const maxRequestBodyBytes = 64 * 1024
// validOptionActions is the allowlist for option order actions.
var validOptionActions = map[string]bool{
"buy_to_open": true,
"buy_to_close": true,
"sell_to_open": true,
"sell_to_close": true,
}
// validOrderTypes is the allowlist for order types.
var validOrderTypes = map[string]bool{
"market": true,
"limit": true,
"stop": true,
"stop_limit": true,
}
// validTimeInForce is the allowlist for time-in-force values.
var validTimeInForce = map[string]bool{
"day": true,
"gtc": true,
"ioc": true,
"fok": true,
"opg": true,
"cls": true,
}
// validContractTypes is the allowlist for option contract types.
var validContractTypes = map[string]bool{
"call": true,
"put": true,
}
// validSides is the allowlist for routing side parameter.
var validSides = map[string]bool{
"buy": true,
"sell": true,
}
func validateUnderlyingSymbol(symbol string) bool {
return underlyingSymbolRe.MatchString(strings.ToUpper(symbol))
}
func validateOCCSymbol(symbol string) bool {
return occSymbolRe.MatchString(symbol)
}
func validateExpiration(exp string) (time.Time, bool) {
if !expirationRe.MatchString(exp) {
return time.Time{}, false
}
t, err := time.Parse("2006-01-02", exp)
if err != nil {
return time.Time{}, false
}
return t, true
}
// validateExpirationNotPast checks that the expiration is today or in the future.
func validateExpirationNotPast(exp string) bool {
t, ok := validateExpiration(exp)
if !ok {
return false
}
// Compare date only (expirations are end-of-day, so today is valid)
today := time.Now().UTC().Truncate(24 * time.Hour)
return !t.Before(today)
}
func validateStrike(strike string) bool {
if strike == "" {
return false
}
f, err := strconv.ParseFloat(strike, 64)
if err != nil {
return false
}
return f > 0
}
func validateQty(qty string) bool {
if qty == "" {
return false
}
q, err := strconv.Atoi(qty)
if err != nil {
return false
}
return q > 0 && q <= maxOptionOrderQty
}
func validatePrice(price string) bool {
if price == "" {
return true // optional
}
f, err := strconv.ParseFloat(price, 64)
if err != nil {
return false
}
return f >= 0
}
// limitedBody wraps the request body with a byte limit to prevent abuse.
func limitedBody(r *http.Request) io.Reader {
return io.LimitReader(r.Body, maxRequestBodyBytes)
}
// --- Provider resolution ---
// getOptionsProvider type-asserts the provider to OptionsProvider.
func (s *Server) getOptionsProvider(r *http.Request) (provider.OptionsProvider, error) {
p, err := s.getProvider(r)
if err != nil {
return nil, err
}
op, ok := p.(provider.OptionsProvider)
if !ok {
return nil, errProviderNoOptions(chi.URLParam(r, "provider"))
}
return op, nil
}
func errProviderNoOptions(name string) error {
return &providerCapErr{provider: name, capability: "options"}
}
type providerCapErr struct {
provider string
capability string
}
func (e *providerCapErr) Error() string {
return "provider " + e.provider + " does not support " + e.capability
}
// --- Option Chain & Expirations ---
func (s *Server) handleGetOptionExpirations(w http.ResponseWriter, r *http.Request) {
symbol := chi.URLParam(r, "symbol")
if !validateUnderlyingSymbol(symbol) {
writeError(w, http.StatusBadRequest, "invalid symbol: must be 1-6 uppercase letters")
return
}
op, err := s.getOptionsProvider(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
expirations, err := op.GetOptionExpirations(r.Context(), strings.ToUpper(symbol))
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"symbol": strings.ToUpper(symbol),
"expirations": expirations,
})
}
func (s *Server) handleGetOptionChain(w http.ResponseWriter, r *http.Request) {
symbol := chi.URLParam(r, "symbol")
if !validateUnderlyingSymbol(symbol) {
writeError(w, http.StatusBadRequest, "invalid symbol: must be 1-6 uppercase letters")
return
}
expiration := chi.URLParam(r, "expiration")
if expiration == "" {
expiration = r.URL.Query().Get("expiration")
}
if expiration != "" {
if _, ok := validateExpiration(expiration); !ok {
writeError(w, http.StatusBadRequest, "invalid expiration: must be YYYY-MM-DD format")
return
}
}
op, err := s.getOptionsProvider(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
chain, err := op.GetOptionChain(r.Context(), strings.ToUpper(symbol), expiration)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.auditLog.Record(audit.Entry{
Action: audit.ActionOptionChainQuery,
Provider: chi.URLParam(r, "provider"),
Symbol: strings.ToUpper(symbol),
Metadata: map[string]interface{}{"expiration": expiration},
})
writeJSON(w, http.StatusOK, chain)
}
func (s *Server) handleGetOptionQuote(w http.ResponseWriter, r *http.Request) {
contractSymbol := chi.URLParam(r, "contractSymbol")
if !validateOCCSymbol(contractSymbol) {
writeError(w, http.StatusBadRequest, "invalid contract symbol: must match OCC format (e.g. AAPL260418C00150000)")
return
}
op, err := s.getOptionsProvider(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
quote, err := op.GetOptionQuote(r.Context(), contractSymbol)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, quote)
}
func (s *Server) handleGetOptionGreeks(w http.ResponseWriter, r *http.Request) {
contractSymbol := chi.URLParam(r, "contractSymbol")
if !validateOCCSymbol(contractSymbol) {
writeError(w, http.StatusBadRequest, "invalid contract symbol: must match OCC format (e.g. AAPL260418C00150000)")
return
}
op, err := s.getOptionsProvider(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
quote, err := op.GetOptionQuote(r.Context(), contractSymbol)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"symbol": contractSymbol,
"greeks": quote.Greeks,
})
}
// --- Option Orders ---
func (s *Server) handleCreateOptionOrder(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
providerName := chi.URLParam(r, "provider")
start := time.Now()
op, err := s.getOptionsProvider(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var req types.CreateOptionOrderRequest
if err := json.NewDecoder(limitedBody(r)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Validate action (allowlist)
action := strings.ToLower(req.Action)
if !validOptionActions[action] {
writeError(w, http.StatusBadRequest, "action must be one of: buy_to_open, buy_to_close, sell_to_open, sell_to_close")
return
}
req.Action = action
// Validate quantity
if !validateQty(req.Qty) {
writeError(w, http.StatusBadRequest, "qty must be a positive integer no greater than "+strconv.Itoa(maxOptionOrderQty))
return
}
// Validate order type (allowlist)
if req.OrderType == "" {
req.OrderType = "limit"
}
if !validOrderTypes[strings.ToLower(req.OrderType)] {
writeError(w, http.StatusBadRequest, "order_type must be one of: market, limit, stop, stop_limit")
return
}
req.OrderType = strings.ToLower(req.OrderType)
// Validate time in force (allowlist)
if req.TimeInForce == "" {
req.TimeInForce = "day"
}
if !validTimeInForce[strings.ToLower(req.TimeInForce)] {
writeError(w, http.StatusBadRequest, "time_in_force must be one of: day, gtc, ioc, fok, opg, cls")
return
}
req.TimeInForce = strings.ToLower(req.TimeInForce)
// Validate contract identification: either OCC symbol or component parts
if req.ContractSymbol != "" {
if !validateOCCSymbol(req.ContractSymbol) {
writeError(w, http.StatusBadRequest, "invalid contract_symbol: must match OCC format (e.g. AAPL260418C00150000)")
return
}
} else {
// Need symbol + contract_type + strike + expiration
if req.Symbol == "" {
writeError(w, http.StatusBadRequest, "symbol or contract_symbol is required")
return
}
if !validateUnderlyingSymbol(req.Symbol) {
writeError(w, http.StatusBadRequest, "invalid symbol: must be 1-6 uppercase letters")
return
}
if !validContractTypes[strings.ToLower(req.ContractType)] {
writeError(w, http.StatusBadRequest, "contract_type must be 'call' or 'put'")
return
}
if !validateStrike(req.Strike) {
writeError(w, http.StatusBadRequest, "strike must be a positive number")
return
}
if !validateExpirationNotPast(req.Expiration) {
writeError(w, http.StatusBadRequest, "expiration must be a valid future date in YYYY-MM-DD format")
return
}
}
// Validate prices
if req.OrderType == "limit" || req.OrderType == "stop_limit" {
if !validatePrice(req.LimitPrice) || req.LimitPrice == "" {
writeError(w, http.StatusBadRequest, "limit_price is required and must be non-negative for limit/stop_limit orders")
return
}
}
if req.OrderType == "stop" || req.OrderType == "stop_limit" {
if !validatePrice(req.StopPrice) || req.StopPrice == "" {
writeError(w, http.StatusBadRequest, "stop_price is required and must be non-negative for stop/stop_limit orders")
return
}
}
order, err := op.CreateOptionOrder(r.Context(), accountID, &req)
// Audit log regardless of success/failure
status := "success"
var errMsg error
if err != nil {
status = "failed"
errMsg = err
}
symbol := req.ContractSymbol
if symbol == "" {
symbol = req.Symbol
}
s.auditLog.RecordOrder(r.Context(), audit.ActionOptionOrderCreate, providerName, accountID, symbol, req.Action, req.Qty, "", "", status, time.Since(start), errMsg)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusCreated, order)
}
func (s *Server) handleCreateMultiLegOrder(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
providerName := chi.URLParam(r, "provider")
start := time.Now()
op, err := s.getOptionsProvider(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var req types.CreateMultiLegOrderRequest
if err := json.NewDecoder(limitedBody(r)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Validate leg count (min 2, max maxMultiLegLegs)
if len(req.Legs) < 2 {
writeError(w, http.StatusBadRequest, "multi-leg order requires at least 2 legs")
return
}
if len(req.Legs) > maxMultiLegLegs {
writeError(w, http.StatusBadRequest, "multi-leg order supports at most "+strconv.Itoa(maxMultiLegLegs)+" legs")
return
}
// Validate underlying symbol
if req.Symbol == "" {
writeError(w, http.StatusBadRequest, "symbol is required")
return
}
if !validateUnderlyingSymbol(req.Symbol) {
writeError(w, http.StatusBadRequest, "invalid symbol: must be 1-6 uppercase letters")
return
}
// Validate order type
if req.OrderType == "" {
req.OrderType = "limit"
}
if !validOrderTypes[strings.ToLower(req.OrderType)] {
writeError(w, http.StatusBadRequest, "order_type must be one of: market, limit, stop, stop_limit")
return
}
req.OrderType = strings.ToLower(req.OrderType)
// Validate time in force
if req.TimeInForce == "" {
req.TimeInForce = "day"
}
if !validTimeInForce[strings.ToLower(req.TimeInForce)] {
writeError(w, http.StatusBadRequest, "time_in_force must be one of: day, gtc, ioc, fok, opg, cls")
return
}
req.TimeInForce = strings.ToLower(req.TimeInForce)
// Validate each leg
for i, leg := range req.Legs {
prefix := "legs[" + strconv.Itoa(i) + "]"
if leg.ContractSymbol != "" {
if !validateOCCSymbol(leg.ContractSymbol) {
writeError(w, http.StatusBadRequest, prefix+".contract_symbol: invalid OCC format")
return
}
} else {
if !validContractTypes[strings.ToLower(leg.ContractType)] {
writeError(w, http.StatusBadRequest, prefix+".contract_type must be 'call' or 'put'")
return
}
if !validateStrike(leg.Strike) {
writeError(w, http.StatusBadRequest, prefix+".strike must be a positive number")
return
}
if !validateExpirationNotPast(leg.Expiration) {
writeError(w, http.StatusBadRequest, prefix+".expiration must be a valid future date in YYYY-MM-DD format")
return
}
}
action := strings.ToLower(leg.Action)
if !validOptionActions[action] {
writeError(w, http.StatusBadRequest, prefix+".action must be one of: buy_to_open, buy_to_close, sell_to_open, sell_to_close")
return
}
req.Legs[i].Action = action
if !validateQty(leg.Quantity) {
writeError(w, http.StatusBadRequest, prefix+".qty must be a positive integer no greater than "+strconv.Itoa(maxOptionOrderQty))
return
}
}
// Validate limit price for limit orders
if req.OrderType == "limit" || req.OrderType == "stop_limit" {
if !validatePrice(req.LimitPrice) || req.LimitPrice == "" {
writeError(w, http.StatusBadRequest, "limit_price is required for limit/stop_limit orders")
return
}
}
result, err := op.CreateMultiLegOrder(r.Context(), accountID, &req)
// Audit
status := "success"
var errMsg error
orderID := ""
if err != nil {
status = "failed"
errMsg = err
} else if result != nil {
orderID = result.StrategyOrderID
}
s.auditLog.RecordOrder(r.Context(), audit.ActionOptionMultiLeg, providerName, accountID, req.Symbol, req.StrategyType, strconv.Itoa(len(req.Legs))+" legs", orderID, "", status, time.Since(start), errMsg)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusCreated, result)
}
func (s *Server) handleExerciseOption(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
providerName := chi.URLParam(r, "provider")
start := time.Now()
op, err := s.getOptionsProvider(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var req types.ExerciseOptionRequest
if err := json.NewDecoder(limitedBody(r)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ContractSymbol == "" {
writeError(w, http.StatusBadRequest, "contract_symbol is required")
return
}
if !validateOCCSymbol(req.ContractSymbol) {
writeError(w, http.StatusBadRequest, "invalid contract_symbol: must match OCC format (e.g. AAPL260418C00150000)")
return
}
if req.Qty <= 0 || req.Qty > maxOptionOrderQty {
writeError(w, http.StatusBadRequest, "qty must be a positive integer no greater than "+strconv.Itoa(maxOptionOrderQty))
return
}
err = op.ExerciseOption(r.Context(), accountID, req.ContractSymbol, req.Qty)
// Audit
status := "success"
var errMsg error
if err != nil {
status = "failed"
errMsg = err
}
s.auditLog.RecordOrder(r.Context(), audit.ActionOptionExercise, providerName, accountID, req.ContractSymbol, "exercise", strconv.Itoa(req.Qty), "", "", status, time.Since(start), errMsg)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "exercised"})
}
func (s *Server) handleDoNotExercise(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
providerName := chi.URLParam(r, "provider")
start := time.Now()
op, err := s.getOptionsProvider(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var req struct {
ContractSymbol string `json:"contract_symbol"`
}
if err := json.NewDecoder(limitedBody(r)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ContractSymbol == "" {
writeError(w, http.StatusBadRequest, "contract_symbol is required")
return
}
if !validateOCCSymbol(req.ContractSymbol) {
writeError(w, http.StatusBadRequest, "invalid contract_symbol: must match OCC format (e.g. AAPL260418C00150000)")
return
}
err = op.DoNotExercise(r.Context(), accountID, req.ContractSymbol)
// Audit
status := "success"
var errMsg error
if err != nil {
status = "failed"
errMsg = err
}
s.auditLog.RecordOrder(r.Context(), audit.ActionOptionDoNotExercise, providerName, accountID, req.ContractSymbol, "do_not_exercise", "1", "", "", status, time.Since(start), errMsg)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "do_not_exercise"})
}
func (s *Server) handleGetOptionPositions(w http.ResponseWriter, r *http.Request) {
accountID := chi.URLParam(r, "accountId")
op, err := s.getOptionsProvider(r)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
positions, err := op.GetOptionPositions(r.Context(), accountID)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
if positions == nil {
positions = []*types.OptionPosition{}
}
writeJSON(w, http.StatusOK, positions)
}
// --- Smart Options Routing ---
func (s *Server) handleRouteOptionOrder(w http.ResponseWriter, r *http.Request) {
contractSymbol := r.URL.Query().Get("contract")
if contractSymbol == "" {
writeError(w, http.StatusBadRequest, "contract query parameter is required")
return
}
if !validateOCCSymbol(contractSymbol) {
writeError(w, http.StatusBadRequest, "invalid contract: must match OCC format (e.g. AAPL260418C00150000)")
return
}
side := r.URL.Query().Get("side")
if side == "" {
side = "buy"
}
if !validSides[strings.ToLower(side)] {
writeError(w, http.StatusBadRequest, "side must be 'buy' or 'sell'")
return
}
side = strings.ToLower(side)
routes, err := s.sor.RouteOptionOrder(r.Context(), contractSymbol, side)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"contract": contractSymbol,
"side": side,
"routes": routes,
})
}
+303
View File
@@ -0,0 +1,303 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
)
func TestOptionsExpirations404WhenProviderMissing(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
resp, err := authedGet(ts.URL + "/v1/options/nonexistent/expirations/AAPL")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
}
func TestOptionsChainRoute(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
// Alpaca is not registered in setupTestServer (empty registry),
// so this should fail with provider not found.
resp, err := authedGet(ts.URL + "/v1/options/alpaca/chain/AAPL")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for unregistered provider, got %d", resp.StatusCode)
}
}
func TestOptionsOrderValidation(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
// Missing action field - should fail because provider not registered (400)
body := `{"symbol":"AAPL","contract_type":"call","strike":"150","expiration":"2026-04-18","qty":"1","order_type":"limit","limit_price":"5.50","time_in_force":"day"}`
resp, err := authedPost(ts.URL+"/v1/accounts/alpaca/test-acct/options/orders", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
}
func TestMultiLegValidationTooFewLegs(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
body := `{"symbol":"AAPL","legs":[{"contract_type":"call","strike":"150","expiration":"2026-04-18","action":"buy_to_open","qty":"1"}],"order_type":"limit","time_in_force":"day"}`
resp, err := authedPost(ts.URL+"/v1/accounts/alpaca/test-acct/options/multi-leg", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
t.Fatalf("expected error status, got %d", resp.StatusCode)
}
}
func TestExerciseValidation(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
// Missing contract_symbol
body := `{"qty":1}`
resp, err := authedPost(ts.URL+"/v1/accounts/alpaca/test-acct/options/exercise", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
t.Fatalf("expected error status, got %d", resp.StatusCode)
}
}
func TestOptionsRouteEndpoint(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
// No contract param
resp, err := authedGet(ts.URL + "/v1/options/route")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for missing contract param, got %d", resp.StatusCode)
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
errMsg, _ := body["error"].(string)
if !strings.Contains(errMsg, "contract") {
t.Errorf("expected error about contract param, got %q", errMsg)
}
}
// --- Input validation security tests ---
func TestSymbolValidationRejectsInvalid(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
// SQL injection attempt in symbol
resp, err := authedGet(ts.URL + "/v1/options/alpaca/expirations/AAPL'%20OR%201=1")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("SQL injection in symbol: expected 400, got %d", resp.StatusCode)
}
// Path traversal attempt
resp2, err := authedGet(ts.URL + "/v1/options/alpaca/expirations/../../etc")
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
// chi router will likely 404 or 301 on path traversal, but if it reaches the handler, it should 400
if resp2.StatusCode == http.StatusOK || resp2.StatusCode == http.StatusCreated {
t.Fatalf("path traversal in symbol: expected non-200, got %d", resp2.StatusCode)
}
// Numeric injection
resp3, err := authedGet(ts.URL + "/v1/options/alpaca/expirations/12345")
if err != nil {
t.Fatal(err)
}
defer resp3.Body.Close()
if resp3.StatusCode != http.StatusBadRequest {
t.Fatalf("numeric symbol: expected 400, got %d", resp3.StatusCode)
}
}
func TestOCCSymbolValidationInQuoteEndpoint(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
// Invalid OCC format
resp, err := authedGet(ts.URL + "/v1/options/alpaca/quote/NOT_VALID_OCC")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid OCC: expected 400, got %d", resp.StatusCode)
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
errMsg, _ := body["error"].(string)
if !strings.Contains(errMsg, "OCC") {
t.Errorf("expected OCC format error, got %q", errMsg)
}
}
func TestOCCSymbolValidationInGreeksEndpoint(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
// Invalid OCC format (but valid URL path characters)
resp, err := authedGet(ts.URL + "/v1/options/alpaca/greeks/NOT_AN_OCC_SYMBOL")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid OCC in greeks: expected 400, got %d", resp.StatusCode)
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
errMsg, _ := body["error"].(string)
if !strings.Contains(errMsg, "OCC") {
t.Errorf("expected OCC format error, got %q", errMsg)
}
}
func TestRouteOptionValidation(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
// Invalid OCC in route query
resp, err := authedGet(ts.URL + "/v1/options/route?contract=INVALID&side=buy")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid OCC in route: expected 400, got %d", resp.StatusCode)
}
// Invalid side
resp2, err := authedGet(ts.URL + "/v1/options/route?contract=AAPL260418C00150000&side=invalid")
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid side: expected 400, got %d", resp2.StatusCode)
}
}
func TestOptionChainExpirationValidation(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
// Invalid expiration format
resp, err := authedGet(ts.URL + "/v1/options/alpaca/chain/AAPL?expiration=not-a-date")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid expiration: expected 400, got %d", resp.StatusCode)
}
}
func TestValidateOCCSymbolFormat(t *testing.T) {
tests := []struct {
symbol string
want bool
}{
{"AAPL260418C00150000", true},
{"SPY260620P00500000", true},
{"X261231C00001000", true},
{"TSLA260418P00200000", true},
{"", false},
{"AAPL", false},
{"not-valid", false},
{"AAPL260418X00150000", false}, // X is not C or P
{"aapl260418C00150000", false}, // lowercase
{"TOOLONG260418C00150000", false}, // > 6 char root (7 chars)
{"AAPL26041800150000", false}, // missing C/P
{"<script>alert(1)</script>", false},
}
for _, tt := range tests {
got := validateOCCSymbol(tt.symbol)
if got != tt.want {
t.Errorf("validateOCCSymbol(%q) = %v, want %v", tt.symbol, got, tt.want)
}
}
}
func TestValidateUnderlyingSymbol(t *testing.T) {
tests := []struct {
symbol string
want bool
}{
{"AAPL", true},
{"SPY", true},
{"X", true},
{"TSLA", true},
{"aapl", true}, // case-insensitive (uppercased internally)
{"", false},
{"TOOLONG1", false}, // > 6 chars
{"123", false},
{"AAP-L", false},
{"AA PL", false},
{"A.B", false},
}
for _, tt := range tests {
got := validateUnderlyingSymbol(tt.symbol)
if got != tt.want {
t.Errorf("validateUnderlyingSymbol(%q) = %v, want %v", tt.symbol, got, tt.want)
}
}
}
func TestValidateQty(t *testing.T) {
tests := []struct {
qty string
want bool
}{
{"1", true},
{"100", true},
{"10000", true},
{"10001", false},
{"0", false},
{"-1", false},
{"", false},
{"abc", false},
{"1.5", false},
}
for _, tt := range tests {
got := validateQty(tt.qty)
if got != tt.want {
t.Errorf("validateQty(%q) = %v, want %v", tt.qty, got, tt.want)
}
}
}
+13
View File
@@ -226,6 +226,19 @@ func NewServer(registry *provider.Registry, listenAddr string) *Server {
r.Get("/events/{provider}/transfers", s.handleStreamTransferEvents)
r.Get("/events/{provider}/journals", s.handleStreamJournalEvents)
// Options Trading
r.Get("/options/{provider}/expirations/{symbol}", s.handleGetOptionExpirations)
r.Get("/options/{provider}/chain/{symbol}", s.handleGetOptionChain)
r.Get("/options/{provider}/chain/{symbol}/{expiration}", s.handleGetOptionChain)
r.Get("/options/{provider}/quote/{contractSymbol}", s.handleGetOptionQuote)
r.Get("/options/{provider}/greeks/{contractSymbol}", s.handleGetOptionGreeks)
r.Post("/accounts/{provider}/{accountId}/options/orders", s.handleCreateOptionOrder)
r.Post("/accounts/{provider}/{accountId}/options/multi-leg", s.handleCreateMultiLegOrder)
r.Post("/accounts/{provider}/{accountId}/options/exercise", s.handleExerciseOption)
r.Post("/accounts/{provider}/{accountId}/options/do-not-exercise", s.handleDoNotExercise)
r.Get("/accounts/{provider}/{accountId}/options/positions", s.handleGetOptionPositions)
r.Get("/options/route", s.handleRouteOptionOrder)
// Exchange frontend API (provider-agnostic, user-resolved)
r.Route("/exchange", func(r chi.Router) {
r.Get("/assets", s.handleFrontendAssets)
+7
View File
@@ -54,6 +54,13 @@ const (
ActionMarketData Action = "market_data.query"
ActionAuth Action = "auth"
ActionError Action = "error"
// Options trading audit actions
ActionOptionOrderCreate Action = "option.order.create"
ActionOptionMultiLeg Action = "option.order.multi_leg"
ActionOptionExercise Action = "option.exercise"
ActionOptionDoNotExercise Action = "option.do_not_exercise"
ActionOptionChainQuery Action = "option.chain.query"
)
// Hook is called for every audit entry (e.g., for external logging).
+21 -1
View File
@@ -6,6 +6,26 @@ import (
ggrpc "google.golang.org/grpc"
)
// BrokerServiceServer is the service interface that gRPC v1.79+ requires
// for RegisterService. HandlerType must be an interface, not a concrete type.
type BrokerServiceServer interface {
GetQuote(context.Context, *GetQuoteRequest) (*QuoteResponse, error)
GetBBO(context.Context, *GetBBORequest) (*BBOResponse, error)
GetRoutes(context.Context, *GetRoutesRequest) (*GetRoutesResponse, error)
PlaceOrder(context.Context, *PlaceOrderRequest) (*OrderResponse, error)
SmartOrder(context.Context, *SmartOrderRequest) (*OrderResponse, error)
CancelOrder(context.Context, *CancelOrderRequest) (*CancelOrderResponse, error)
GetSplitPlan(context.Context, *GetSplitPlanRequest) (*SplitPlanResponse, error)
ExecuteSplit(context.Context, *ExecuteSplitRequest) (*ExecutionResultResponse, error)
StartTWAP(context.Context, *StartTWAPRequest) (*TWAPResponse, error)
CancelTWAP(context.Context, *CancelTWAPRequest) (*TWAPResponse, error)
GetTWAP(context.Context, *GetTWAPRequest) (*TWAPResponse, error)
ScanArbitrage(context.Context, *ScanArbitrageRequest) (*ScanArbitrageResponse, error)
ListProviders(context.Context, *ListProvidersRequest) (*ListProvidersResponse, error)
HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error)
StreamQuotes(*StreamQuotesRequest, ggrpc.ServerStream) error
}
// brokerServiceDesc is the gRPC ServiceDesc for the BrokerService.
// It registers unary handlers for all RPCs defined in proto/broker.proto.
// The StreamQuotes server-streaming RPC is registered as a stream handler.
@@ -13,7 +33,7 @@ import (
// This will be replaced by protoc-generated registration once brokerpb/ is generated.
var brokerServiceDesc = ggrpc.ServiceDesc{
ServiceName: "broker.v1.BrokerService",
HandlerType: (*Server)(nil),
HandlerType: (*BrokerServiceServer)(nil),
Methods: []ggrpc.MethodDesc{
{
MethodName: "GetQuote",
+564
View File
@@ -0,0 +1,564 @@
package alpaca
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/luxfi/broker/pkg/types"
)
// Alpaca options contract response from /v1/options/contracts
type alpacaOptionContract struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
Name string `json:"name"`
Status string `json:"status"`
Tradable bool `json:"tradable"`
ExpirationDate string `json:"expiration_date"`
RootSymbol string `json:"root_symbol"`
UnderlyingSymbol string `json:"underlying_symbol"`
Type string `json:"type"` // call, put
Style string `json:"style"`
StrikePrice string `json:"strike_price"`
Size string `json:"size"`
OpenInterest string `json:"open_interest"`
ClosePrice string `json:"close_price"`
OpenPrice string `json:"open_price"`
}
// Alpaca option snapshot from data API
type alpacaOptionSnapshot struct {
LatestQuote *struct {
Timestamp string `json:"t"`
BidPrice float64 `json:"bp"`
BidSize int `json:"bs"`
AskPrice float64 `json:"ap"`
AskSize int `json:"as"`
} `json:"latestQuote"`
LatestTrade *struct {
Timestamp string `json:"t"`
Price float64 `json:"p"`
Size int `json:"s"`
} `json:"latestTrade"`
Greeks *struct {
Delta float64 `json:"delta"`
Gamma float64 `json:"gamma"`
Theta float64 `json:"theta"`
Vega float64 `json:"vega"`
Rho float64 `json:"rho"`
IV float64 `json:"implied_volatility"`
} `json:"greeks"`
}
func (c *alpacaOptionContract) toUnified() types.OptionContract {
strike, _ := strconv.ParseFloat(c.StrikePrice, 64)
oi, _ := strconv.Atoi(c.OpenInterest)
close, _ := strconv.ParseFloat(c.ClosePrice, 64)
return types.OptionContract{
Symbol: c.Symbol,
Underlying: c.UnderlyingSymbol,
ContractType: c.Type,
Strike: strike,
Expiration: c.ExpirationDate,
Style: c.Style,
Status: c.Status,
Tradable: c.Tradable,
Last: close,
OpenInterest: oi,
}
}
// GetOptionChain returns all contracts for a symbol and expiration.
// Alpaca API: GET /v1/options/contracts?underlying_symbols={symbol}&expiration_date={date}
func (p *Provider) GetOptionChain(ctx context.Context, symbol string, expiration string) (*types.OptionChain, error) {
params := url.Values{}
params.Set("underlying_symbols", strings.ToUpper(symbol))
if expiration != "" {
params.Set("expiration_date", expiration)
}
params.Set("status", "active")
data, _, err := p.do(ctx, http.MethodGet, "/v1/options/contracts?"+params.Encode(), nil)
if err != nil {
return nil, fmt.Errorf("get option chain: %w", err)
}
// Alpaca returns paginated response
var resp struct {
Contracts []alpacaOptionContract `json:"option_contracts"`
NextPageToken string `json:"next_page_token"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("decode option chain: %w", err)
}
chain := &types.OptionChain{
Symbol: strings.ToUpper(symbol),
Expiration: expiration,
}
// Fetch snapshots for pricing data if we got contracts
snapshots := make(map[string]*alpacaOptionSnapshot)
if len(resp.Contracts) > 0 {
symbols := make([]string, 0, len(resp.Contracts))
for _, c := range resp.Contracts {
symbols = append(symbols, c.Symbol)
}
snapshots = p.fetchOptionSnapshots(ctx, symbols)
}
for _, c := range resp.Contracts {
oc := c.toUnified()
// Merge snapshot data (quotes + greeks)
if snap, ok := snapshots[c.Symbol]; ok {
if snap.LatestQuote != nil {
oc.Bid = snap.LatestQuote.BidPrice
oc.Ask = snap.LatestQuote.AskPrice
}
if snap.LatestTrade != nil {
oc.Last = snap.LatestTrade.Price
}
if snap.Greeks != nil {
oc.Greeks = types.Greeks{
Delta: snap.Greeks.Delta,
Gamma: snap.Greeks.Gamma,
Theta: snap.Greeks.Theta,
Vega: snap.Greeks.Vega,
Rho: snap.Greeks.Rho,
IV: snap.Greeks.IV,
}
}
}
switch c.Type {
case "call":
chain.Calls = append(chain.Calls, oc)
case "put":
chain.Puts = append(chain.Puts, oc)
}
}
return chain, nil
}
// GetOptionExpirations returns available expiration dates for a symbol.
// Uses the contracts endpoint filtered by underlying to extract unique dates.
func (p *Provider) GetOptionExpirations(ctx context.Context, symbol string) ([]string, error) {
params := url.Values{}
params.Set("underlying_symbols", strings.ToUpper(symbol))
params.Set("status", "active")
data, _, err := p.do(ctx, http.MethodGet, "/v1/options/contracts?"+params.Encode(), nil)
if err != nil {
return nil, fmt.Errorf("get option expirations: %w", err)
}
var resp struct {
Contracts []alpacaOptionContract `json:"option_contracts"`
NextPageToken string `json:"next_page_token"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("decode option expirations: %w", err)
}
seen := make(map[string]bool)
var expirations []string
for _, c := range resp.Contracts {
if !seen[c.ExpirationDate] {
seen[c.ExpirationDate] = true
expirations = append(expirations, c.ExpirationDate)
}
}
return expirations, nil
}
// GetOptionQuote returns a real-time quote for a specific contract symbol.
// Alpaca API: GET /v1/options/contracts/{symbol_or_id}
func (p *Provider) GetOptionQuote(ctx context.Context, contractSymbol string) (*types.OptionQuote, error) {
// Fetch contract metadata
data, _, err := p.do(ctx, http.MethodGet, "/v1/options/contracts/"+url.PathEscape(contractSymbol), nil)
if err != nil {
return nil, fmt.Errorf("get option contract: %w", err)
}
var contract alpacaOptionContract
if err := json.Unmarshal(data, &contract); err != nil {
return nil, fmt.Errorf("decode option contract: %w", err)
}
strike, _ := strconv.ParseFloat(contract.StrikePrice, 64)
oi, _ := strconv.Atoi(contract.OpenInterest)
close, _ := strconv.ParseFloat(contract.ClosePrice, 64)
quote := &types.OptionQuote{
Symbol: contract.Symbol,
Underlying: contract.UnderlyingSymbol,
ContractType: contract.Type,
Strike: strike,
Expiration: contract.ExpirationDate,
Last: close,
OpenInterest: oi,
}
// Fetch snapshot for live pricing + greeks
snapshots := p.fetchOptionSnapshots(ctx, []string{contract.Symbol})
if snap, ok := snapshots[contract.Symbol]; ok {
if snap.LatestQuote != nil {
quote.Bid = snap.LatestQuote.BidPrice
quote.Ask = snap.LatestQuote.AskPrice
}
if snap.LatestTrade != nil {
quote.Last = snap.LatestTrade.Price
}
if snap.Greeks != nil {
quote.Greeks = types.Greeks{
Delta: snap.Greeks.Delta,
Gamma: snap.Greeks.Gamma,
Theta: snap.Greeks.Theta,
Vega: snap.Greeks.Vega,
Rho: snap.Greeks.Rho,
IV: snap.Greeks.IV,
}
}
}
return quote, nil
}
// CreateOptionOrder places a single-leg option order via Alpaca.
// Alpaca uses the same orders endpoint with asset_class: "us_option".
func (p *Provider) CreateOptionOrder(ctx context.Context, accountID string, req *types.CreateOptionOrderRequest) (*types.Order, error) {
// Build the OCC symbol if not provided
contractSymbol := req.ContractSymbol
if contractSymbol == "" {
sym, err := buildOCCSymbol(req.Symbol, req.Expiration, req.ContractType, req.Strike)
if err != nil {
return nil, err
}
contractSymbol = sym
}
// Map action to Alpaca side
side, err := actionToSide(req.Action)
if err != nil {
return nil, err
}
body := map[string]interface{}{
"symbol": contractSymbol,
"qty": req.Qty,
"side": side,
"type": req.OrderType,
"time_in_force": req.TimeInForce,
"order_class": "simple",
}
if req.LimitPrice != "" {
body["limit_price"] = req.LimitPrice
}
if req.StopPrice != "" {
body["stop_price"] = req.StopPrice
}
data, _, err := p.do(ctx, http.MethodPost, "/v1/trading/accounts/"+accountID+"/orders", body)
if err != nil {
return nil, fmt.Errorf("create option order: %w", err)
}
order, err := p.parseOrder(data)
if err != nil {
return nil, err
}
order.AssetClass = "us_option"
return order, nil
}
// CreateMultiLegOrder places a multi-leg strategy order via Alpaca.
func (p *Provider) CreateMultiLegOrder(ctx context.Context, accountID string, req *types.CreateMultiLegOrderRequest) (*types.MultiLegOrderResult, error) {
if len(req.Legs) < 2 {
return nil, fmt.Errorf("multi-leg order requires at least 2 legs")
}
legs := make([]map[string]interface{}, 0, len(req.Legs))
for _, leg := range req.Legs {
contractSymbol := leg.ContractSymbol
if contractSymbol == "" {
sym, err := buildOCCSymbol(req.Symbol, leg.Expiration, leg.ContractType, leg.Strike)
if err != nil {
return nil, fmt.Errorf("leg %s %s: %w", leg.ContractType, leg.Strike, err)
}
contractSymbol = sym
}
side, err := actionToSide(leg.Action)
if err != nil {
return nil, fmt.Errorf("leg %s: %w", contractSymbol, err)
}
legs = append(legs, map[string]interface{}{
"symbol": contractSymbol,
"side": side,
"ratio_qty": leg.Quantity,
"position_intent": leg.Action,
})
}
body := map[string]interface{}{
"symbol": strings.ToUpper(req.Symbol),
"order_class": "mleg",
"legs": legs,
"type": req.OrderType,
"time_in_force": req.TimeInForce,
}
if req.LimitPrice != "" {
body["limit_price"] = req.LimitPrice
}
data, _, err := p.do(ctx, http.MethodPost, "/v1/trading/accounts/"+accountID+"/orders", body)
if err != nil {
return nil, fmt.Errorf("create multi-leg order: %w", err)
}
order, err := p.parseOrder(data)
if err != nil {
return nil, err
}
return &types.MultiLegOrderResult{
StrategyOrderID: order.ProviderID,
LegOrders: []*types.Order{order},
NetPremium: req.LimitPrice,
Status: order.Status,
}, nil
}
// ExerciseOption exercises an option contract.
// Alpaca uses POST /v1/trading/accounts/{account_id}/positions/{symbol}/exercise
func (p *Provider) ExerciseOption(ctx context.Context, accountID string, contractSymbol string, qty int) error {
body := map[string]interface{}{}
if qty > 0 {
body["qty"] = strconv.Itoa(qty)
}
_, _, err := p.do(ctx, http.MethodPost, "/v1/trading/accounts/"+accountID+"/positions/"+url.PathEscape(contractSymbol)+"/exercise", body)
if err != nil {
return fmt.Errorf("exercise option: %w", err)
}
return nil
}
// GetOptionPositions returns all option positions for an account.
// Filters positions with asset_class = "us_option" from the standard positions endpoint.
func (p *Provider) GetOptionPositions(ctx context.Context, accountID string) ([]*types.OptionPosition, error) {
data, _, err := p.do(ctx, http.MethodGet, "/v1/trading/accounts/"+accountID+"/positions", nil)
if err != nil {
return nil, fmt.Errorf("get option positions: %w", err)
}
var rawPositions []struct {
Symbol string `json:"symbol"`
Qty string `json:"qty"`
AvgEntryPrice string `json:"avg_entry_price"`
MarketValue string `json:"market_value"`
CurrentPrice string `json:"current_price"`
UnrealizedPL string `json:"unrealized_pl"`
Side string `json:"side"`
AssetClass string `json:"asset_class"`
}
if err := json.Unmarshal(data, &rawPositions); err != nil {
return nil, fmt.Errorf("decode positions: %w", err)
}
var positions []*types.OptionPosition
for _, rp := range rawPositions {
if rp.AssetClass != "us_option" {
continue
}
contractType, strike, expiration := parseOCCSymbol(rp.Symbol)
underlying := extractUnderlying(rp.Symbol)
positions = append(positions, &types.OptionPosition{
Symbol: rp.Symbol,
Underlying: underlying,
ContractType: contractType,
Strike: strike,
Expiration: expiration,
Qty: rp.Qty,
AvgCost: rp.AvgEntryPrice,
MarketValue: rp.MarketValue,
CurrentPrice: rp.CurrentPrice,
UnrealizedPnL: rp.UnrealizedPL,
Side: rp.Side,
})
}
// Fetch greeks for option positions
if len(positions) > 0 {
symbols := make([]string, len(positions))
for i, pos := range positions {
symbols[i] = pos.Symbol
}
snapshots := p.fetchOptionSnapshots(ctx, symbols)
for _, pos := range positions {
if snap, ok := snapshots[pos.Symbol]; ok && snap.Greeks != nil {
pos.Greeks = types.Greeks{
Delta: snap.Greeks.Delta,
Gamma: snap.Greeks.Gamma,
Theta: snap.Greeks.Theta,
Vega: snap.Greeks.Vega,
Rho: snap.Greeks.Rho,
IV: snap.Greeks.IV,
}
}
}
}
return positions, nil
}
// DoNotExercise marks an option contract as do-not-exercise.
// Alpaca API: POST /v1/trading/accounts/{account_id}/positions/{symbol}/do-not-exercise
func (p *Provider) DoNotExercise(ctx context.Context, accountID, contractSymbol string) error {
_, _, err := p.do(ctx, http.MethodPost, "/v1/trading/accounts/"+accountID+"/positions/"+url.PathEscape(contractSymbol)+"/do-not-exercise", nil)
if err != nil {
return fmt.Errorf("do not exercise: %w", err)
}
return nil
}
// SetOptionsApprovalLevel sets the options approval level for an account.
// Alpaca API: POST /v1/accounts/{account_id}/options/approval
func (p *Provider) SetOptionsApprovalLevel(ctx context.Context, accountID string, level int) error {
body := map[string]interface{}{
"level": level,
}
_, _, err := p.do(ctx, http.MethodPost, "/v1/accounts/"+accountID+"/options/approval", body)
if err != nil {
return fmt.Errorf("set options approval level: %w", err)
}
return nil
}
// fetchOptionSnapshots retrieves snapshots (quotes + greeks) from the data API.
// GET /v1beta1/options/snapshots?symbols={syms}
func (p *Provider) fetchOptionSnapshots(ctx context.Context, symbols []string) map[string]*alpacaOptionSnapshot {
result := make(map[string]*alpacaOptionSnapshot)
if len(symbols) == 0 {
return result
}
// Batch in groups of 100
for i := 0; i < len(symbols); i += 100 {
end := i + 100
if end > len(symbols) {
end = len(symbols)
}
batch := symbols[i:end]
params := url.Values{}
params.Set("symbols", strings.Join(batch, ","))
data, _, err := p.doData(ctx, http.MethodGet, "/v1beta1/options/snapshots?"+params.Encode())
if err != nil {
continue
}
var resp struct {
Snapshots map[string]*alpacaOptionSnapshot `json:"snapshots"`
}
if err := json.Unmarshal(data, &resp); err != nil {
continue
}
for k, v := range resp.Snapshots {
result[k] = v
}
}
return result
}
// buildOCCSymbol constructs an OCC-format symbol.
// Format: SYMBOLYYMMDDTSSSSSSSS where T=C/P, S=strike*1000 zero-padded to 8 digits.
// Example: AAPL260418C00150000 = AAPL $150 Call expiring 2026-04-18
func buildOCCSymbol(underlying, expiration, contractType, strike string) (string, error) {
if underlying == "" || expiration == "" || contractType == "" || strike == "" {
return "", fmt.Errorf("symbol, expiration, contract_type, and strike are required")
}
t, err := time.Parse("2006-01-02", expiration)
if err != nil {
return "", fmt.Errorf("invalid expiration date %q: %w", expiration, err)
}
strikeF, err := strconv.ParseFloat(strike, 64)
if err != nil {
return "", fmt.Errorf("invalid strike %q: %w", strike, err)
}
typeChar := "C"
if strings.EqualFold(contractType, "put") {
typeChar = "P"
}
// OCC strike is price * 1000, zero-padded to 8 digits
strikeInt := int(strikeF * 1000)
return fmt.Sprintf("%s%s%s%08d", strings.ToUpper(underlying), t.Format("060102"), typeChar, strikeInt), nil
}
// actionToSide maps option action (buy_to_open, etc) to Alpaca's side (buy/sell).
func actionToSide(action string) (string, error) {
switch strings.ToLower(action) {
case "buy_to_open", "buy_to_close":
return "buy", nil
case "sell_to_open", "sell_to_close":
return "sell", nil
default:
return "", fmt.Errorf("invalid option action %q: must be buy_to_open, buy_to_close, sell_to_open, or sell_to_close", action)
}
}
// parseOCCSymbol extracts contract type, strike, and expiration from an OCC symbol.
// Returns ("call"/"put", strike float64, "YYYY-MM-DD").
func parseOCCSymbol(occ string) (string, float64, string) {
if len(occ) < 15 {
return "", 0, ""
}
// Find the C or P that separates date from strike
// OCC format: ROOT(1-6)DATE(6)TYPE(1)STRIKE(8)
// Scan from end: last 8 = strike, then 1 = type, then 6 = date, rest = root
strikeStr := occ[len(occ)-8:]
typeChar := occ[len(occ)-9 : len(occ)-8]
dateStr := occ[len(occ)-15 : len(occ)-9]
contractType := "call"
if typeChar == "P" {
contractType = "put"
}
strikeInt, _ := strconv.Atoi(strikeStr)
strike := float64(strikeInt) / 1000.0
var expiration string
if t, err := time.Parse("060102", dateStr); err == nil {
expiration = t.Format("2006-01-02")
}
return contractType, strike, expiration
}
// extractUnderlying extracts the underlying symbol from an OCC option symbol.
func extractUnderlying(occ string) string {
if len(occ) < 15 {
return occ
}
return occ[:len(occ)-15]
}
+847
View File
@@ -0,0 +1,847 @@
package alpaca
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/luxfi/broker/pkg/types"
)
func TestBuildOCCSymbol(t *testing.T) {
tests := []struct {
name string
underlying string
expiration string
ctype string
strike string
want string
wantErr bool
}{
{
name: "AAPL call 150",
underlying: "AAPL",
expiration: "2026-04-18",
ctype: "call",
strike: "150",
want: "AAPL260418C00150000",
},
{
name: "AAPL put 145.50",
underlying: "AAPL",
expiration: "2026-04-18",
ctype: "put",
strike: "145.50",
want: "AAPL260418P00145500",
},
{
name: "SPY call 500",
underlying: "SPY",
expiration: "2026-06-20",
ctype: "call",
strike: "500",
want: "SPY260620C00500000",
},
{
name: "missing symbol",
underlying: "",
expiration: "2026-04-18",
ctype: "call",
strike: "150",
wantErr: true,
},
{
name: "bad date",
underlying: "AAPL",
expiration: "not-a-date",
ctype: "call",
strike: "150",
wantErr: true,
},
{
name: "bad strike",
underlying: "AAPL",
expiration: "2026-04-18",
ctype: "call",
strike: "abc",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := buildOCCSymbol(tt.underlying, tt.expiration, tt.ctype, tt.strike)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
func TestParseOCCSymbol(t *testing.T) {
tests := []struct {
occ string
wantType string
wantStrike float64
wantExp string
}{
{"AAPL260418C00150000", "call", 150.0, "2026-04-18"},
{"AAPL260418P00145500", "put", 145.5, "2026-04-18"},
{"SPY260620C00500000", "call", 500.0, "2026-06-20"},
}
for _, tt := range tests {
t.Run(tt.occ, func(t *testing.T) {
ct, strike, exp := parseOCCSymbol(tt.occ)
if ct != tt.wantType {
t.Errorf("contract_type: got %q, want %q", ct, tt.wantType)
}
if strike != tt.wantStrike {
t.Errorf("strike: got %f, want %f", strike, tt.wantStrike)
}
if exp != tt.wantExp {
t.Errorf("expiration: got %q, want %q", exp, tt.wantExp)
}
})
}
}
func TestExtractUnderlying(t *testing.T) {
tests := []struct {
occ string
want string
}{
{"AAPL260418C00150000", "AAPL"},
{"SPY260620C00500000", "SPY"},
{"TSLA260418P00200000", "TSLA"},
}
for _, tt := range tests {
got := extractUnderlying(tt.occ)
if got != tt.want {
t.Errorf("extractUnderlying(%q) = %q, want %q", tt.occ, got, tt.want)
}
}
}
func TestActionToSide(t *testing.T) {
tests := []struct {
action string
want string
wantErr bool
}{
{"buy_to_open", "buy", false},
{"buy_to_close", "buy", false},
{"sell_to_open", "sell", false},
{"sell_to_close", "sell", false},
{"BUY_TO_OPEN", "buy", false},
{"invalid", "", true},
}
for _, tt := range tests {
got, err := actionToSide(tt.action)
if tt.wantErr {
if err == nil {
t.Errorf("actionToSide(%q): expected error", tt.action)
}
continue
}
if err != nil {
t.Errorf("actionToSide(%q): unexpected error: %v", tt.action, err)
continue
}
if got != tt.want {
t.Errorf("actionToSide(%q) = %q, want %q", tt.action, got, tt.want)
}
}
}
func TestGetOptionExpirations(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/options/contracts": func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
symbols := r.URL.Query().Get("underlying_symbols")
if symbols != "AAPL" {
http.Error(w, "unexpected symbol", http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"option_contracts": []map[string]interface{}{
{"symbol": "AAPL260418C00150000", "expiration_date": "2026-04-18", "underlying_symbol": "AAPL", "type": "call", "strike_price": "150"},
{"symbol": "AAPL260418P00150000", "expiration_date": "2026-04-18", "underlying_symbol": "AAPL", "type": "put", "strike_price": "150"},
{"symbol": "AAPL260515C00150000", "expiration_date": "2026-05-15", "underlying_symbol": "AAPL", "type": "call", "strike_price": "150"},
},
})
},
})
exps, err := p.GetOptionExpirations(context.Background(), "AAPL")
if err != nil {
t.Fatalf("GetOptionExpirations: %v", err)
}
if len(exps) != 2 {
t.Fatalf("expected 2 expirations, got %d: %v", len(exps), exps)
}
if exps[0] != "2026-04-18" || exps[1] != "2026-05-15" {
t.Fatalf("unexpected expirations: %v", exps)
}
}
func TestGetOptionChain(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/options/contracts": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"option_contracts": []map[string]interface{}{
{
"symbol": "AAPL260418C00150000",
"expiration_date": "2026-04-18",
"underlying_symbol": "AAPL",
"type": "call",
"strike_price": "150",
"status": "active",
"tradable": true,
"style": "american",
"open_interest": "1000",
"close_price": "5.50",
},
{
"symbol": "AAPL260418P00150000",
"expiration_date": "2026-04-18",
"underlying_symbol": "AAPL",
"type": "put",
"strike_price": "150",
"status": "active",
"tradable": true,
"style": "american",
"open_interest": "800",
"close_price": "3.20",
},
},
})
},
})
// Patch data URL to same server (snapshots will 404, which is fine)
chain, err := p.GetOptionChain(context.Background(), "AAPL", "2026-04-18")
if err != nil {
t.Fatalf("GetOptionChain: %v", err)
}
if chain.Symbol != "AAPL" {
t.Errorf("Symbol = %q, want AAPL", chain.Symbol)
}
if len(chain.Calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(chain.Calls))
}
if len(chain.Puts) != 1 {
t.Fatalf("expected 1 put, got %d", len(chain.Puts))
}
if chain.Calls[0].Strike != 150 {
t.Errorf("call strike = %f, want 150", chain.Calls[0].Strike)
}
if chain.Puts[0].Strike != 150 {
t.Errorf("put strike = %f, want 150", chain.Puts[0].Strike)
}
if chain.Calls[0].OpenInterest != 1000 {
t.Errorf("call OI = %d, want 1000", chain.Calls[0].OpenInterest)
}
if !chain.Calls[0].Tradable {
t.Error("expected call to be tradable")
}
}
func TestGetOptionQuote(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/options/contracts/AAPL260418C00150000": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"symbol": "AAPL260418C00150000",
"expiration_date": "2026-04-18",
"underlying_symbol": "AAPL",
"type": "call",
"strike_price": "150",
"status": "active",
"tradable": true,
"open_interest": "1000",
"close_price": "5.50",
})
},
})
quote, err := p.GetOptionQuote(context.Background(), "AAPL260418C00150000")
if err != nil {
t.Fatalf("GetOptionQuote: %v", err)
}
if quote.Symbol != "AAPL260418C00150000" {
t.Errorf("Symbol = %q", quote.Symbol)
}
if quote.Strike != 150 {
t.Errorf("Strike = %f, want 150", quote.Strike)
}
if quote.ContractType != "call" {
t.Errorf("ContractType = %q, want call", quote.ContractType)
}
if quote.Underlying != "AAPL" {
t.Errorf("Underlying = %q, want AAPL", quote.Underlying)
}
}
func TestCreateOptionOrder(t *testing.T) {
var gotBody map[string]interface{}
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/trading/accounts/test-acct/orders": func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
json.NewDecoder(r.Body).Decode(&gotBody)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "order-123",
"symbol": "AAPL260418C00150000",
"qty": "1",
"side": "buy",
"type": "limit",
"time_in_force": "day",
"limit_price": "5.50",
"status": "accepted",
"asset_class": "us_option",
"created_at": "2026-04-01T10:00:00Z",
})
},
})
order, err := p.CreateOptionOrder(context.Background(), "test-acct", &types.CreateOptionOrderRequest{
Symbol: "AAPL",
ContractType: "call",
Strike: "150",
Expiration: "2026-04-18",
Action: "buy_to_open",
Qty: "1",
OrderType: "limit",
LimitPrice: "5.50",
TimeInForce: "day",
})
if err != nil {
t.Fatalf("CreateOptionOrder: %v", err)
}
if order.ProviderID != "order-123" {
t.Errorf("ProviderID = %q, want order-123", order.ProviderID)
}
if order.Status != "accepted" {
t.Errorf("Status = %q, want accepted", order.Status)
}
if order.AssetClass != "us_option" {
t.Errorf("AssetClass = %q, want us_option", order.AssetClass)
}
// Verify the request body sent to Alpaca
if gotBody["symbol"] != "AAPL260418C00150000" {
t.Errorf("body symbol = %v, want AAPL260418C00150000", gotBody["symbol"])
}
if gotBody["side"] != "buy" {
t.Errorf("body side = %v, want buy", gotBody["side"])
}
if gotBody["order_class"] != "simple" {
t.Errorf("body order_class = %v, want simple", gotBody["order_class"])
}
}
func TestCreateOptionOrderWithContractSymbol(t *testing.T) {
var gotBody map[string]interface{}
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/trading/accounts/test-acct/orders": func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&gotBody)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "order-456",
"symbol": "AAPL260418C00150000",
"qty": "1",
"side": "sell",
"type": "market",
"status": "accepted",
"asset_class": "us_option",
"created_at": "2026-04-01T10:00:00Z",
})
},
})
order, err := p.CreateOptionOrder(context.Background(), "test-acct", &types.CreateOptionOrderRequest{
ContractSymbol: "AAPL260418C00150000",
Action: "sell_to_close",
Qty: "1",
OrderType: "market",
TimeInForce: "day",
})
if err != nil {
t.Fatalf("CreateOptionOrder: %v", err)
}
if order.ProviderID != "order-456" {
t.Errorf("ProviderID = %q", order.ProviderID)
}
if gotBody["symbol"] != "AAPL260418C00150000" {
t.Errorf("body symbol = %v, want AAPL260418C00150000", gotBody["symbol"])
}
if gotBody["side"] != "sell" {
t.Errorf("body side = %v, want sell", gotBody["side"])
}
}
func TestCreateMultiLegOrder(t *testing.T) {
var gotBody map[string]interface{}
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/trading/accounts/test-acct/orders": func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&gotBody)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "multileg-789",
"symbol": "AAPL",
"status": "accepted",
"created_at": "2026-04-01T10:00:00Z",
})
},
})
result, err := p.CreateMultiLegOrder(context.Background(), "test-acct", &types.CreateMultiLegOrderRequest{
Symbol: "AAPL",
StrategyType: "vertical",
Legs: []types.OptionLeg{
{ContractType: "call", Strike: "150", Expiration: "2026-04-18", Action: "buy_to_open", Quantity: "1"},
{ContractType: "call", Strike: "155", Expiration: "2026-04-18", Action: "sell_to_open", Quantity: "1"},
},
OrderType: "limit",
LimitPrice: "2.00",
TimeInForce: "day",
})
if err != nil {
t.Fatalf("CreateMultiLegOrder: %v", err)
}
if result.StrategyOrderID != "multileg-789" {
t.Errorf("StrategyOrderID = %q", result.StrategyOrderID)
}
if result.Status != "accepted" {
t.Errorf("Status = %q, want accepted", result.Status)
}
// Verify mleg body structure
if gotBody["order_class"] != "mleg" {
t.Errorf("order_class = %v, want mleg", gotBody["order_class"])
}
if gotBody["symbol"] != "AAPL" {
t.Errorf("symbol = %v, want AAPL", gotBody["symbol"])
}
rawLegs, ok := gotBody["legs"].([]interface{})
if !ok || len(rawLegs) != 2 {
t.Fatalf("expected 2 legs, got %v", gotBody["legs"])
}
// Verify leg fields use ratio_qty and position_intent (not qty)
leg0, ok := rawLegs[0].(map[string]interface{})
if !ok {
t.Fatal("leg[0] is not a map")
}
if leg0["ratio_qty"] != "1" {
t.Errorf("leg[0].ratio_qty = %v, want 1", leg0["ratio_qty"])
}
if leg0["position_intent"] != "buy_to_open" {
t.Errorf("leg[0].position_intent = %v, want buy_to_open", leg0["position_intent"])
}
if _, hasQty := leg0["qty"]; hasQty {
t.Error("leg[0] should not have 'qty' field, should use 'ratio_qty'")
}
leg1, ok := rawLegs[1].(map[string]interface{})
if !ok {
t.Fatal("leg[1] is not a map")
}
if leg1["ratio_qty"] != "1" {
t.Errorf("leg[1].ratio_qty = %v, want 1", leg1["ratio_qty"])
}
if leg1["position_intent"] != "sell_to_open" {
t.Errorf("leg[1].position_intent = %v, want sell_to_open", leg1["position_intent"])
}
}
func TestCreateMultiLegOrderTooFewLegs(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{})
_, err := p.CreateMultiLegOrder(context.Background(), "test-acct", &types.CreateMultiLegOrderRequest{
Symbol: "AAPL",
Legs: []types.OptionLeg{{ContractType: "call", Strike: "150", Expiration: "2026-04-18", Action: "buy_to_open", Quantity: "1"}},
})
if err == nil {
t.Fatal("expected error for < 2 legs")
}
}
func TestExerciseOption(t *testing.T) {
var gotPath string
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/trading/accounts/test-acct/positions/AAPL260418C00150000/exercise": func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("{}"))
},
})
err := p.ExerciseOption(context.Background(), "test-acct", "AAPL260418C00150000", 1)
if err != nil {
t.Fatalf("ExerciseOption: %v", err)
}
if gotPath != "/v1/trading/accounts/test-acct/positions/AAPL260418C00150000/exercise" {
t.Errorf("unexpected path: %s", gotPath)
}
}
func TestGetOptionPositions(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/trading/accounts/test-acct/positions": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"symbol": "AAPL",
"qty": "100",
"avg_entry_price": "175.50",
"market_value": "17550",
"current_price": "175.50",
"unrealized_pl": "0",
"side": "long",
"asset_class": "us_equity",
},
{
"symbol": "AAPL260418C00150000",
"qty": "5",
"avg_entry_price": "5.50",
"market_value": "2750",
"current_price": "5.50",
"unrealized_pl": "0",
"side": "long",
"asset_class": "us_option",
},
{
"symbol": "AAPL260418P00145000",
"qty": "-3",
"avg_entry_price": "3.20",
"market_value": "-960",
"current_price": "3.20",
"unrealized_pl": "0",
"side": "short",
"asset_class": "us_option",
},
})
},
})
positions, err := p.GetOptionPositions(context.Background(), "test-acct")
if err != nil {
t.Fatalf("GetOptionPositions: %v", err)
}
if len(positions) != 2 {
t.Fatalf("expected 2 option positions (equity filtered out), got %d", len(positions))
}
// First option: AAPL call
if positions[0].Symbol != "AAPL260418C00150000" {
t.Errorf("pos[0].Symbol = %q", positions[0].Symbol)
}
if positions[0].Underlying != "AAPL" {
t.Errorf("pos[0].Underlying = %q, want AAPL", positions[0].Underlying)
}
if positions[0].ContractType != "call" {
t.Errorf("pos[0].ContractType = %q, want call", positions[0].ContractType)
}
if positions[0].Strike != 150 {
t.Errorf("pos[0].Strike = %f, want 150", positions[0].Strike)
}
if positions[0].Qty != "5" {
t.Errorf("pos[0].Qty = %q, want 5", positions[0].Qty)
}
if positions[0].Side != "long" {
t.Errorf("pos[0].Side = %q, want long", positions[0].Side)
}
// Second option: AAPL put (short)
if positions[1].ContractType != "put" {
t.Errorf("pos[1].ContractType = %q, want put", positions[1].ContractType)
}
if positions[1].Strike != 145 {
t.Errorf("pos[1].Strike = %f, want 145", positions[1].Strike)
}
if positions[1].Side != "short" {
t.Errorf("pos[1].Side = %q, want short", positions[1].Side)
}
}
func TestGetOptionChainWithSnapshots(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/options/contracts": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"option_contracts": []map[string]interface{}{
{
"symbol": "AAPL260418C00150000",
"expiration_date": "2026-04-18",
"underlying_symbol": "AAPL",
"type": "call",
"strike_price": "150",
"status": "active",
"tradable": true,
"open_interest": "1000",
"close_price": "5.50",
},
},
})
},
})
// Set up data server with snapshot
testDataServer(t, p, map[string]http.HandlerFunc{
"/v1beta1/options/snapshots": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"snapshots": map[string]interface{}{
"AAPL260418C00150000": map[string]interface{}{
"latestQuote": map[string]interface{}{
"bp": 5.40,
"ap": 5.60,
},
"latestTrade": map[string]interface{}{
"p": 5.50,
},
"greeks": map[string]interface{}{
"delta": 0.65,
"gamma": 0.03,
"theta": -0.05,
"vega": 0.15,
"rho": 0.01,
"implied_volatility": 0.30,
},
},
},
})
},
})
chain, err := p.GetOptionChain(context.Background(), "AAPL", "2026-04-18")
if err != nil {
t.Fatalf("GetOptionChain: %v", err)
}
if len(chain.Calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(chain.Calls))
}
call := chain.Calls[0]
if call.Bid != 5.40 {
t.Errorf("Bid = %f, want 5.40", call.Bid)
}
if call.Ask != 5.60 {
t.Errorf("Ask = %f, want 5.60", call.Ask)
}
if call.Last != 5.50 {
t.Errorf("Last = %f, want 5.50", call.Last)
}
if call.Greeks.Delta != 0.65 {
t.Errorf("Delta = %f, want 0.65", call.Greeks.Delta)
}
if call.Greeks.IV != 0.30 {
t.Errorf("IV = %f, want 0.30", call.Greeks.IV)
}
}
func TestDoNotExercise(t *testing.T) {
var gotPath string
var gotMethod string
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/trading/accounts/test-acct/positions/AAPL260418C00150000/do-not-exercise": func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotMethod = r.Method
w.WriteHeader(http.StatusOK)
w.Write([]byte("{}"))
},
})
err := p.DoNotExercise(context.Background(), "test-acct", "AAPL260418C00150000")
if err != nil {
t.Fatalf("DoNotExercise: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method = %q, want POST", gotMethod)
}
if gotPath != "/v1/trading/accounts/test-acct/positions/AAPL260418C00150000/do-not-exercise" {
t.Errorf("unexpected path: %s", gotPath)
}
}
func TestSetOptionsApprovalLevel(t *testing.T) {
var gotBody map[string]interface{}
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/accounts/test-acct/options/approval": func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusOK)
w.Write([]byte("{}"))
},
})
err := p.SetOptionsApprovalLevel(context.Background(), "test-acct", 3)
if err != nil {
t.Fatalf("SetOptionsApprovalLevel: %v", err)
}
// JSON numbers decode as float64
if gotBody["level"] != float64(3) {
t.Errorf("level = %v, want 3", gotBody["level"])
}
}
// --- Security-focused tests ---
func TestBuildOCCSymbolNegativeStrike(t *testing.T) {
// Negative strikes should still produce a valid OCC symbol
// (the exchange may reject it, but the format function should not panic)
sym, err := buildOCCSymbol("AAPL", "2026-04-18", "call", "-150")
if err != nil {
// Negative strikes are invalid in practice; if we reject them that's fine
return
}
// If it doesn't error, verify it doesn't produce garbage
if sym == "" {
t.Error("expected non-empty symbol for negative strike")
}
}
func TestBuildOCCSymbolZeroStrike(t *testing.T) {
sym, err := buildOCCSymbol("AAPL", "2026-04-18", "call", "0")
if err != nil {
return // acceptable to reject zero strikes
}
if sym == "" {
t.Error("expected non-empty symbol for zero strike")
}
}
func TestBuildOCCSymbolVeryLargeStrike(t *testing.T) {
// Strike of $99999.999 -> 99999999 which is 8 digits (max for OCC format)
sym, err := buildOCCSymbol("SPY", "2026-04-18", "call", "99999.999")
if err != nil {
t.Fatalf("unexpected error for large strike: %v", err)
}
if sym != "SPY260418C99999999" {
t.Errorf("got %q, want SPY260418C99999999", sym)
}
}
func TestBuildOCCSymbolOverflowStrike(t *testing.T) {
// Strike of $100000 -> 100000000 which overflows 8-digit OCC format
sym, err := buildOCCSymbol("SPY", "2026-04-18", "call", "100000")
if err != nil {
return // Acceptable to reject overflow
}
// If it doesn't error, the symbol will be >8 digits which is invalid OCC
// This is a known limitation; the real validation happens at handler level
_ = sym
}
func TestParseOCCSymbolShortInput(t *testing.T) {
// Should not panic on short strings
ct, strike, exp := parseOCCSymbol("")
if ct != "" || strike != 0 || exp != "" {
t.Errorf("expected empty results for empty input")
}
ct2, strike2, exp2 := parseOCCSymbol("ABC")
if ct2 != "" || strike2 != 0 || exp2 != "" {
t.Errorf("expected empty results for short input")
}
}
func TestExtractUnderlyingShortInput(t *testing.T) {
// Should not panic on short strings
result := extractUnderlying("")
if result != "" {
t.Errorf("expected empty for empty input, got %q", result)
}
result2 := extractUnderlying("ABC")
if result2 != "ABC" {
t.Errorf("expected ABC for short input, got %q", result2)
}
}
func TestCreateMultiLegOrderMaxLegs(t *testing.T) {
// Verify the provider-level code enforces minimum 2 legs
_, p := testServer(t, map[string]http.HandlerFunc{})
// 0 legs
_, err := p.CreateMultiLegOrder(context.Background(), "test-acct", &types.CreateMultiLegOrderRequest{
Symbol: "AAPL",
Legs: []types.OptionLeg{},
})
if err == nil {
t.Fatal("expected error for 0 legs")
}
// 1 leg
_, err = p.CreateMultiLegOrder(context.Background(), "test-acct", &types.CreateMultiLegOrderRequest{
Symbol: "AAPL",
Legs: []types.OptionLeg{{ContractType: "call", Strike: "150", Expiration: "2026-04-18", Action: "buy_to_open", Quantity: "1"}},
})
if err == nil {
t.Fatal("expected error for 1 leg")
}
}
func TestContractSymbolURLEscaping(t *testing.T) {
// Verify that contract symbols with special chars are properly URL-escaped
// This prevents path traversal in exercise/do-not-exercise endpoints
var gotPath string
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/trading/accounts/test-acct/positions/AAPL260418C00150000/exercise": func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.WriteHeader(http.StatusOK)
w.Write([]byte("{}"))
},
})
err := p.ExerciseOption(context.Background(), "test-acct", "AAPL260418C00150000", 1)
if err != nil {
t.Fatalf("ExerciseOption: %v", err)
}
// Verify the path is correct and not manipulated
expected := "/v1/trading/accounts/test-acct/positions/AAPL260418C00150000/exercise"
if gotPath != expected {
t.Errorf("path = %q, want %q", gotPath, expected)
}
}
func TestActionToSideEdgeCases(t *testing.T) {
// Verify we don't accept partial matches or similar-looking actions
badActions := []string{
"buy",
"sell",
"buy_open",
"sell_close",
"buy_to_",
"buy_to_open_extra",
"BUY_TO_OPEN_EXTRA",
"",
" buy_to_open",
"buy_to_open ",
"buy_to_open\n",
}
for _, action := range badActions {
_, err := actionToSide(action)
if err == nil {
t.Errorf("actionToSide(%q): expected error, got nil", action)
}
}
}
+35
View File
@@ -0,0 +1,35 @@
package provider
import (
"context"
"github.com/luxfi/broker/pkg/types"
)
// OptionsProvider is an optional interface for providers that support options trading.
// Providers like Alpaca, IBKR, and Tradier implement this interface.
type OptionsProvider interface {
// GetOptionChain returns all contracts for a symbol and expiration date.
GetOptionChain(ctx context.Context, symbol string, expiration string) (*types.OptionChain, error)
// GetOptionExpirations returns available expiration dates for a symbol.
GetOptionExpirations(ctx context.Context, symbol string) ([]string, error)
// GetOptionQuote returns a real-time quote for a specific contract.
GetOptionQuote(ctx context.Context, contractSymbol string) (*types.OptionQuote, error)
// CreateOptionOrder places a single-leg option order.
CreateOptionOrder(ctx context.Context, accountID string, req *types.CreateOptionOrderRequest) (*types.Order, error)
// CreateMultiLegOrder places a multi-leg strategy order (spread, straddle, etc).
CreateMultiLegOrder(ctx context.Context, accountID string, req *types.CreateMultiLegOrderRequest) (*types.MultiLegOrderResult, error)
// ExerciseOption exercises an option contract early.
ExerciseOption(ctx context.Context, accountID string, contractSymbol string, qty int) error
// DoNotExercise marks an option contract as do-not-exercise at expiration.
DoNotExercise(ctx context.Context, accountID string, contractSymbol string) error
// GetOptionPositions returns all option positions for an account.
GetOptionPositions(ctx context.Context, accountID string) ([]*types.OptionPosition, error)
}
+118 -7
View File
@@ -424,6 +424,117 @@ func (r *Router) SmartOrder(ctx context.Context, accountsByProvider map[string]s
return order, nil
}
// OptionRouteResult contains routing decision details for an options order.
type OptionRouteResult struct {
Provider string `json:"provider"`
Contract string `json:"contract"`
Bid float64 `json:"bid,omitempty"`
Ask float64 `json:"ask,omitempty"`
Spread float64 `json:"spread,omitempty"`
SpreadBps float64 `json:"spread_bps,omitempty"`
IV float64 `json:"implied_volatility,omitempty"`
NetPrice float64 `json:"net_price,omitempty"`
Score float64 `json:"score"`
}
// RouteOptionOrder finds the best provider for an options contract.
// It queries all providers that implement OptionsProvider, compares quotes, and
// returns routes ranked by net execution price (spread + fees).
func (r *Router) RouteOptionOrder(ctx context.Context, contractSymbol, side string) ([]*OptionRouteResult, error) {
providerNames := r.registry.List()
if len(providerNames) == 0 {
return nil, fmt.Errorf("no providers registered")
}
type result struct {
route *OptionRouteResult
}
ch := make(chan result, len(providerNames))
var wg sync.WaitGroup
for _, name := range providerNames {
wg.Add(1)
go func(provName string) {
defer wg.Done()
p, err := r.registry.Get(provName)
if err != nil {
return
}
// Check if provider supports options
op, ok := p.(provider.OptionsProvider)
if !ok {
return
}
quote, err := op.GetOptionQuote(ctx, contractSymbol)
if err != nil {
return
}
route := &OptionRouteResult{
Provider: provName,
Contract: contractSymbol,
Bid: quote.Bid,
Ask: quote.Ask,
IV: quote.Greeks.IV,
Score: 1000,
}
// Get fee schedule
r.mu.RLock()
fees := r.fees[provName]
r.mu.RUnlock()
feeBps := 0.0
if fees != nil {
feeBps = fees.TakerBps
}
if quote.Ask > 0 && quote.Bid > 0 {
route.Spread = quote.Ask - quote.Bid
mid := (quote.Ask + quote.Bid) / 2
if mid > 0 {
route.SpreadBps = (route.Spread / mid) * 10000
}
if strings.EqualFold(side, "buy") {
route.NetPrice = quote.Ask * (1 + feeBps/10000)
route.Score = route.NetPrice
} else {
route.NetPrice = quote.Bid * (1 - feeBps/10000)
route.Score = -route.NetPrice
}
}
ch <- result{route: route}
}(name)
}
go func() {
wg.Wait()
close(ch)
}()
var routes []*OptionRouteResult
for res := range ch {
if res.route != nil {
routes = append(routes, res.route)
}
}
if len(routes) == 0 {
return nil, fmt.Errorf("no provider supports options contract %s", contractSymbol)
}
sort.Slice(routes, func(i, j int) bool {
return routes[i].Score < routes[j].Score
})
return routes, nil
}
// AggregatedAssets returns all tradable assets across all providers, deduplicated.
func (r *Router) AggregatedAssets(ctx context.Context) ([]*AggregatedAsset, error) {
providerNames := r.registry.List()
@@ -509,9 +620,9 @@ func defaultCapabilities() map[string]*types.ProviderCapability {
return map[string]*types.ProviderCapability{
"alpaca": {
Name: "alpaca", Status: "active",
AssetClasses: []string{"us_equity", "crypto"},
AssetClasses: []string{"us_equity", "us_option", "crypto"},
OrderTypes: []string{"market", "limit", "stop", "stop_limit", "trailing_stop"},
Features: []string{"ach", "wire", "fractional", "extended_hours", "margin"},
Features: []string{"ach", "wire", "fractional", "extended_hours", "margin", "options", "multi_leg"},
},
"sfox": {
Name: "sfox", Status: "active",
@@ -545,9 +656,9 @@ func defaultCapabilities() map[string]*types.ProviderCapability {
},
"ibkr": {
Name: "ibkr", Status: "active",
AssetClasses: []string{"us_equity", "option", "futures", "forex", "bond", "crypto"},
AssetClasses: []string{"us_equity", "us_option", "futures", "forex", "bond", "crypto"},
OrderTypes: []string{"market", "limit", "stop", "stop_limit", "trailing_stop", "algo"},
Features: []string{"margin", "short_selling", "options", "futures", "global_markets"},
Features: []string{"margin", "short_selling", "options", "multi_leg", "futures", "global_markets"},
},
"bitgo": {
Name: "bitgo", Status: "active",
@@ -581,13 +692,13 @@ func defaultCapabilities() map[string]*types.ProviderCapability {
},
"tradier": {
Name: "tradier", Status: "active",
AssetClasses: []string{"us_equity", "option"},
AssetClasses: []string{"us_equity", "us_option"},
OrderTypes: []string{"market", "limit", "stop", "stop_limit"},
Features: []string{"commission_free", "options", "streaming", "extended_hours"},
Features: []string{"commission_free", "options", "multi_leg", "streaming", "extended_hours"},
},
"polygon": {
Name: "polygon", Status: "active",
AssetClasses: []string{"us_equity", "crypto", "forex", "option"},
AssetClasses: []string{"us_equity", "crypto", "forex", "us_option"},
OrderTypes: []string{},
Features: []string{"market_data", "snapshots", "bars", "trades", "quotes", "real_time"},
},
+118
View File
@@ -685,3 +685,121 @@ type CorporateActionParams struct {
Until string `json:"until,omitempty"`
Symbol string `json:"symbol,omitempty"`
}
// --- Options Types ---
// OptionChain is the full set of contracts for a symbol and expiration.
type OptionChain struct {
Symbol string `json:"symbol"`
Expiration string `json:"expiration"`
Calls []OptionContract `json:"calls"`
Puts []OptionContract `json:"puts"`
}
// OptionContract is a single option contract in a chain.
type OptionContract struct {
Symbol string `json:"symbol"` // OCC symbol, e.g. AAPL260418C00150000
Underlying string `json:"underlying"`
ContractType string `json:"contract_type"` // call, put
Strike float64 `json:"strike"`
Expiration string `json:"expiration"` // YYYY-MM-DD
Style string `json:"style"` // american, european
Status string `json:"status"`
Tradable bool `json:"tradable"`
Bid float64 `json:"bid"`
Ask float64 `json:"ask"`
Last float64 `json:"last"`
Volume int `json:"volume"`
OpenInterest int `json:"open_interest"`
Greeks Greeks `json:"greeks"`
}
// Greeks are the option sensitivity measures.
type Greeks struct {
Delta float64 `json:"delta"`
Gamma float64 `json:"gamma"`
Theta float64 `json:"theta"`
Vega float64 `json:"vega"`
Rho float64 `json:"rho"`
IV float64 `json:"implied_volatility"`
}
// OptionQuote is a real-time quote for a single option contract.
type OptionQuote struct {
Symbol string `json:"symbol"`
Underlying string `json:"underlying"`
ContractType string `json:"contract_type"`
Strike float64 `json:"strike"`
Expiration string `json:"expiration"`
Bid float64 `json:"bid"`
Ask float64 `json:"ask"`
Last float64 `json:"last"`
Volume int `json:"volume"`
OpenInterest int `json:"open_interest"`
Greeks Greeks `json:"greeks"`
}
// CreateOptionOrderRequest places a single-leg option order.
type CreateOptionOrderRequest struct {
Symbol string `json:"symbol"` // underlying symbol, e.g. "AAPL"
ContractSymbol string `json:"contract_symbol,omitempty"` // OCC symbol if known
ContractType string `json:"contract_type"` // call, put
Strike string `json:"strike"`
Expiration string `json:"expiration"` // YYYY-MM-DD
Action string `json:"action"` // buy_to_open, buy_to_close, sell_to_open, sell_to_close
Qty string `json:"qty"`
OrderType string `json:"order_type"` // market, limit, stop, stop_limit
LimitPrice string `json:"limit_price,omitempty"`
StopPrice string `json:"stop_price,omitempty"`
TimeInForce string `json:"time_in_force"` // day, gtc, ioc
}
// CreateMultiLegOrderRequest places a multi-leg strategy order.
type CreateMultiLegOrderRequest struct {
Symbol string `json:"symbol"` // underlying symbol
StrategyType string `json:"strategy_type"` // vertical, iron_condor, straddle, strangle, calendar, custom
Legs []OptionLeg `json:"legs"`
OrderType string `json:"order_type"` // limit, market
LimitPrice string `json:"limit_price,omitempty"` // net debit/credit
TimeInForce string `json:"time_in_force"`
}
// OptionLeg is a single leg of a multi-leg options strategy.
type OptionLeg struct {
ContractSymbol string `json:"contract_symbol,omitempty"` // OCC symbol if known
ContractType string `json:"contract_type"` // call, put
Strike string `json:"strike"`
Expiration string `json:"expiration"`
Action string `json:"action"` // buy_to_open, buy_to_close, sell_to_open, sell_to_close
Quantity string `json:"qty"`
}
// MultiLegOrderResult is the result of placing a multi-leg order.
type MultiLegOrderResult struct {
StrategyOrderID string `json:"strategy_order_id"`
LegOrders []*Order `json:"leg_orders,omitempty"`
NetPremium string `json:"net_premium,omitempty"`
Status string `json:"status"`
}
// ExerciseOptionRequest exercises an option contract early.
type ExerciseOptionRequest struct {
ContractSymbol string `json:"contract_symbol"`
Qty int `json:"qty"`
}
// OptionPosition is a held option position.
type OptionPosition struct {
Symbol string `json:"symbol"` // OCC symbol
Underlying string `json:"underlying"`
ContractType string `json:"contract_type"`
Strike float64 `json:"strike"`
Expiration string `json:"expiration"`
Qty string `json:"qty"`
AvgCost string `json:"avg_cost"`
MarketValue string `json:"market_value"`
CurrentPrice string `json:"current_price"`
UnrealizedPnL string `json:"unrealized_pnl"`
Side string `json:"side"` // long, short
Greeks Greeks `json:"greeks"`
}