mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
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:
+27
-17
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"},
|
||||
},
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user