mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
feat: federated broker with 16 providers, smart order routing, institutional trading
Providers: Alpaca, IBKR, BitGo, FalconX, Finix, SFOX, Coinbase, Binance, Kraken, Gemini, Fireblocks, Circle, Tradier, Polygon, CurrencyCloud, LMAX Features: fee-aware SOR, order splitting, pre-trade risk, audit logging, consolidated market data, SSE streaming, auth middleware
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
brokerd
|
||||
.env
|
||||
*.exe
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
FROM golang:1.26-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o brokerd ./cmd/brokerd/
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates
|
||||
COPY --from=builder /app/brokerd /usr/local/bin/brokerd
|
||||
EXPOSE 8090
|
||||
ENTRYPOINT ["brokerd"]
|
||||
@@ -0,0 +1,16 @@
|
||||
.PHONY: build run test docker
|
||||
|
||||
build:
|
||||
go build -o brokerd ./cmd/brokerd/
|
||||
|
||||
run: build
|
||||
ALPACA_API_KEY=$(ALPACA_API_KEY) ALPACA_API_SECRET=$(ALPACA_API_SECRET) ./brokerd
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
docker:
|
||||
docker build --platform linux/amd64 -t ghcr.io/luxfi/broker:latest .
|
||||
|
||||
clean:
|
||||
rm -f brokerd
|
||||
@@ -0,0 +1,15 @@
|
||||
module github.com/luxfi/broker
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/rs/zerolog v1.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
@@ -0,0 +1,918 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimw "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
|
||||
"github.com/luxfi/broker/pkg/audit"
|
||||
"github.com/luxfi/broker/pkg/auth"
|
||||
"github.com/luxfi/broker/pkg/marketdata"
|
||||
"github.com/luxfi/broker/pkg/provider"
|
||||
"github.com/luxfi/broker/pkg/risk"
|
||||
"github.com/luxfi/broker/pkg/router"
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
"github.com/luxfi/broker/pkg/ws"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
registry *provider.Registry
|
||||
sor *router.Router
|
||||
riskEng *risk.Engine
|
||||
auditLog *audit.Log
|
||||
authStore *auth.Store
|
||||
feed *marketdata.Feed
|
||||
stream *ws.Server
|
||||
router chi.Router
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
func NewServer(registry *provider.Registry, listenAddr string) *Server {
|
||||
s := &Server{
|
||||
registry: registry,
|
||||
sor: router.New(registry),
|
||||
riskEng: risk.NewEngine(risk.DefaultLimits()),
|
||||
auditLog: audit.NewLog(),
|
||||
authStore: auth.NewStore(),
|
||||
feed: marketdata.NewFeed(),
|
||||
}
|
||||
s.stream = ws.NewServer(s.feed)
|
||||
|
||||
// Register API keys from environment
|
||||
if key := os.Getenv("BROKER_API_KEY"); key != "" {
|
||||
s.authStore.Add(&auth.APIKey{
|
||||
Key: key,
|
||||
Name: "default",
|
||||
OrgID: os.Getenv("BROKER_ORG_ID"),
|
||||
Permissions: []string{"admin"},
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(chimw.RequestID)
|
||||
r.Use(chimw.RealIP)
|
||||
r.Use(chimw.Recoverer)
|
||||
r.Use(chimw.Timeout(60 * time.Second))
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"https://lux.financial", "https://app.lux.financial", "https://mpc.lux.network", "http://localhost:3000"},
|
||||
AllowedMethods: []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300,
|
||||
}))
|
||||
r.Use(auth.Middleware(s.authStore))
|
||||
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"status": "ok",
|
||||
"providers": registry.List(),
|
||||
})
|
||||
})
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Get("/providers", s.handleListProviders)
|
||||
r.Get("/providers/capabilities", s.handleGetCapabilities)
|
||||
|
||||
r.Get("/accounts", s.handleListAccounts)
|
||||
r.Post("/accounts", s.handleCreateAccount)
|
||||
r.Get("/accounts/{provider}/{accountId}", s.handleGetAccount)
|
||||
r.Get("/accounts/{provider}/{accountId}/portfolio", s.handleGetPortfolio)
|
||||
|
||||
r.Get("/accounts/{provider}/{accountId}/orders", s.handleListOrders)
|
||||
r.Post("/accounts/{provider}/{accountId}/orders", s.handleCreateOrder)
|
||||
r.Get("/accounts/{provider}/{accountId}/orders/{orderId}", s.handleGetOrder)
|
||||
r.Delete("/accounts/{provider}/{accountId}/orders/{orderId}", s.handleCancelOrder)
|
||||
|
||||
r.Get("/accounts/{provider}/{accountId}/transfers", s.handleListTransfers)
|
||||
r.Post("/accounts/{provider}/{accountId}/transfers", s.handleCreateTransfer)
|
||||
|
||||
r.Get("/accounts/{provider}/{accountId}/bank-relationships", s.handleListBankRelationships)
|
||||
r.Post("/accounts/{provider}/{accountId}/bank-relationships", s.handleCreateBankRelationship)
|
||||
|
||||
r.Get("/assets/{provider}", s.handleListAssets)
|
||||
r.Get("/assets/{provider}/{symbolOrId}", s.handleGetAsset)
|
||||
|
||||
// Smart Order Routing
|
||||
r.Get("/route/{symbol}", s.handleGetRoutes)
|
||||
r.Get("/route/{symbol}/{quote}", s.handleGetRoutesPair)
|
||||
r.Get("/route/{symbol}/split", s.handleGetSplitPlan)
|
||||
r.Get("/route/{symbol}/{quote}/split", s.handleGetSplitPlanPair)
|
||||
r.Get("/assets", s.handleAggregatedAssets)
|
||||
r.Post("/smart-order", s.handleSmartOrder)
|
||||
r.Post("/smart-order/split", s.handleExecuteSplit)
|
||||
|
||||
// Market Data
|
||||
r.Get("/market/{provider}/snapshot/{symbol}", s.handleGetSnapshot)
|
||||
r.Get("/market/{provider}/snapshots", s.handleGetSnapshots)
|
||||
r.Get("/market/{provider}/bars/{symbol}", s.handleGetBars)
|
||||
r.Get("/market/{provider}/trades/latest", s.handleGetLatestTrades)
|
||||
r.Get("/market/{provider}/quotes/latest", s.handleGetLatestQuotes)
|
||||
r.Get("/market/{provider}/clock", s.handleGetClock)
|
||||
r.Get("/market/{provider}/calendar", s.handleGetCalendar)
|
||||
|
||||
// Consolidated Market Data
|
||||
r.Get("/bbo/{symbol}", s.handleGetBBO)
|
||||
r.Get("/bbo/{symbol}/{quote}", s.handleGetBBOPair)
|
||||
r.Get("/stream", s.stream.HandleSSE)
|
||||
|
||||
// Risk & Audit
|
||||
r.Get("/risk/check", s.handleRiskCheck)
|
||||
r.Get("/audit", s.handleAuditQuery)
|
||||
r.Get("/audit/stats", s.handleAuditStats)
|
||||
r.Get("/audit/export", s.handleAuditExport)
|
||||
})
|
||||
|
||||
s.router = r
|
||||
s.server = &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: r,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
return s.server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
return s.server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func (s *Server) getProvider(r *http.Request) (provider.Provider, error) {
|
||||
return s.registry.Get(chi.URLParam(r, "provider"))
|
||||
}
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
func (s *Server) handleListProviders(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"providers": s.registry.List()})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetCapabilities(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.sor.GetCapabilities())
|
||||
}
|
||||
|
||||
func (s *Server) handleListAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
providerName := r.URL.Query().Get("provider")
|
||||
if providerName == "" {
|
||||
all := make([]*types.Account, 0)
|
||||
for _, name := range s.registry.List() {
|
||||
p, _ := s.registry.Get(name)
|
||||
accts, err := p.ListAccounts(r.Context())
|
||||
if err == nil {
|
||||
all = append(all, accts...)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, all)
|
||||
return
|
||||
}
|
||||
p, err := s.registry.Get(providerName)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
accts, err := p.ListAccounts(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, accts)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
DateOfBirth string `json:"date_of_birth"`
|
||||
TaxID string `json:"tax_id"`
|
||||
TaxIDType string `json:"tax_id_type"`
|
||||
CountryOfTax string `json:"country_of_tax_residence"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Street []string `json:"street"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
PostalCode string `json:"postal_code"`
|
||||
Country string `json:"country"`
|
||||
EnabledAssets []string `json:"enabled_assets"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Provider == "" {
|
||||
req.Provider = "alpaca"
|
||||
}
|
||||
|
||||
p, err := s.registry.Get(req.Provider)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
acct, err := p.CreateAccount(r.Context(), &types.CreateAccountRequest{
|
||||
Provider: req.Provider,
|
||||
Identity: &types.Identity{
|
||||
GivenName: req.GivenName, FamilyName: req.FamilyName,
|
||||
DateOfBirth: req.DateOfBirth, TaxID: req.TaxID,
|
||||
TaxIDType: req.TaxIDType, CountryOfTax: req.CountryOfTax,
|
||||
},
|
||||
Contact: &types.Contact{
|
||||
Email: req.Email, Phone: req.Phone, Street: req.Street,
|
||||
City: req.City, State: req.State, PostalCode: req.PostalCode, Country: req.Country,
|
||||
},
|
||||
EnabledAssets: req.EnabledAssets,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.auditLog.Record(audit.Entry{
|
||||
Action: audit.ActionAccountCreate,
|
||||
Provider: req.Provider,
|
||||
AccountID: acct.ID,
|
||||
Status: "success",
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, acct)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetAccount(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
acct, err := p.GetAccount(r.Context(), chi.URLParam(r, "accountId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, acct)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetPortfolio(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
portfolio, err := p.GetPortfolio(r.Context(), chi.URLParam(r, "accountId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, portfolio)
|
||||
}
|
||||
|
||||
func (s *Server) handleListOrders(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
orders, err := p.ListOrders(r.Context(), chi.URLParam(r, "accountId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, orders)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateOrder(w http.ResponseWriter, r *http.Request) {
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
accountID := chi.URLParam(r, "accountId")
|
||||
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var req types.CreateOrderRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-trade risk check
|
||||
riskResult := s.riskEng.PreTradeCheck(risk.CheckRequest{
|
||||
Provider: providerName,
|
||||
AccountID: accountID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
Qty: req.Qty,
|
||||
OrderType: req.Type,
|
||||
})
|
||||
if !riskResult.Allowed {
|
||||
s.auditLog.Record(audit.Entry{
|
||||
Action: audit.ActionOrderReject,
|
||||
Provider: providerName,
|
||||
AccountID: accountID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
Qty: req.Qty,
|
||||
Status: "rejected",
|
||||
Metadata: map[string]interface{}{"risk_errors": riskResult.Errors},
|
||||
})
|
||||
writeJSON(w, http.StatusForbidden, map[string]interface{}{
|
||||
"error": "risk check failed",
|
||||
"details": riskResult.Errors,
|
||||
"warnings": riskResult.Warnings,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
order, err := p.CreateOrder(r.Context(), accountID, &req)
|
||||
latency := time.Since(start)
|
||||
|
||||
status := "success"
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
}
|
||||
|
||||
s.auditLog.RecordOrder(r.Context(), audit.ActionOrderCreate,
|
||||
providerName, accountID, req.Symbol, req.Side, req.Qty,
|
||||
"", "", status, latency, err)
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.riskEng.RecordOrder(providerName, accountID, 0) // TODO: pass estimated value
|
||||
writeJSON(w, http.StatusCreated, order)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetOrder(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
order, err := p.GetOrder(r.Context(), chi.URLParam(r, "accountId"), chi.URLParam(r, "orderId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, order)
|
||||
}
|
||||
|
||||
func (s *Server) handleCancelOrder(w http.ResponseWriter, r *http.Request) {
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
accountID := chi.URLParam(r, "accountId")
|
||||
orderID := chi.URLParam(r, "orderId")
|
||||
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := p.CancelOrder(r.Context(), accountID, orderID); err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.auditLog.Record(audit.Entry{
|
||||
Action: audit.ActionOrderCancel,
|
||||
Provider: providerName,
|
||||
AccountID: accountID,
|
||||
OrderID: orderID,
|
||||
Status: "success",
|
||||
})
|
||||
|
||||
s.riskEng.RecordFill(providerName, accountID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"})
|
||||
}
|
||||
|
||||
func (s *Server) handleListTransfers(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
transfers, err := p.ListTransfers(r.Context(), chi.URLParam(r, "accountId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transfers)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTransfer(w http.ResponseWriter, r *http.Request) {
|
||||
providerName := chi.URLParam(r, "provider")
|
||||
accountID := chi.URLParam(r, "accountId")
|
||||
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var req types.CreateTransferRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
transfer, err := p.CreateTransfer(r.Context(), accountID, &req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.auditLog.Record(audit.Entry{
|
||||
Action: audit.ActionTransfer,
|
||||
Provider: providerName,
|
||||
AccountID: accountID,
|
||||
Status: "success",
|
||||
Metadata: map[string]interface{}{"type": req.Type, "direction": req.Direction, "amount": req.Amount},
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusCreated, transfer)
|
||||
}
|
||||
|
||||
func (s *Server) handleListBankRelationships(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
rels, err := p.ListBankRelationships(r.Context(), chi.URLParam(r, "accountId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rels)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateBankRelationship(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
AccountOwnerName string `json:"account_owner_name"`
|
||||
BankAccountType string `json:"bank_account_type"`
|
||||
BankAccountNumber string `json:"bank_account_number"`
|
||||
BankRoutingNumber string `json:"bank_routing_number"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
rel, err := p.CreateBankRelationship(r.Context(), chi.URLParam(r, "accountId"),
|
||||
req.AccountOwnerName, req.BankAccountType, req.BankAccountNumber, req.BankRoutingNumber)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, rel)
|
||||
}
|
||||
|
||||
func (s *Server) handleListAssets(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
class := r.URL.Query().Get("class")
|
||||
assets, err := p.ListAssets(r.Context(), class)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("all") == "" {
|
||||
tradable := make([]*types.Asset, 0, len(assets))
|
||||
for _, a := range assets {
|
||||
if a.Tradable {
|
||||
tradable = append(tradable, a)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tradable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, assets)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetAsset(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
asset, err := p.GetAsset(r.Context(), chi.URLParam(r, "symbolOrId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, asset)
|
||||
}
|
||||
|
||||
// --- Smart Order Routing Handlers ---
|
||||
|
||||
func (s *Server) handleGetRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
symbol := chi.URLParam(r, "symbol")
|
||||
side := r.URL.Query().Get("side")
|
||||
if side == "" {
|
||||
side = "buy"
|
||||
}
|
||||
routes, err := s.sor.GetAllRoutes(r.Context(), symbol, side)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, routes)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetRoutesPair(w http.ResponseWriter, r *http.Request) {
|
||||
symbol := chi.URLParam(r, "symbol") + "/" + chi.URLParam(r, "quote")
|
||||
side := r.URL.Query().Get("side")
|
||||
if side == "" {
|
||||
side = "buy"
|
||||
}
|
||||
routes, err := s.sor.GetAllRoutes(r.Context(), symbol, side)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, routes)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSplitPlan(w http.ResponseWriter, r *http.Request) {
|
||||
symbol := chi.URLParam(r, "symbol")
|
||||
q := r.URL.Query()
|
||||
side := q.Get("side")
|
||||
if side == "" {
|
||||
side = "buy"
|
||||
}
|
||||
qty := q.Get("qty")
|
||||
if qty == "" {
|
||||
writeError(w, http.StatusBadRequest, "qty query param required")
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := s.sor.BuildSplitPlan(r.Context(), symbol, side, qty)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.auditLog.Record(audit.Entry{
|
||||
Action: audit.ActionSplitPlan,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Qty: qty,
|
||||
Algorithm: plan.Algorithm,
|
||||
Status: "success",
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, plan)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSplitPlanPair(w http.ResponseWriter, r *http.Request) {
|
||||
symbol := chi.URLParam(r, "symbol") + "/" + chi.URLParam(r, "quote")
|
||||
q := r.URL.Query()
|
||||
side := q.Get("side")
|
||||
if side == "" {
|
||||
side = "buy"
|
||||
}
|
||||
qty := q.Get("qty")
|
||||
if qty == "" {
|
||||
writeError(w, http.StatusBadRequest, "qty query param required")
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := s.sor.BuildSplitPlan(r.Context(), symbol, side, qty)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, plan)
|
||||
}
|
||||
|
||||
func (s *Server) handleSmartOrder(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Qty string `json:"qty"`
|
||||
Side string `json:"side"`
|
||||
Type string `json:"type"`
|
||||
TimeInForce string `json:"time_in_force"`
|
||||
LimitPrice string `json:"limit_price,omitempty"`
|
||||
Accounts map[string]string `json:"accounts"` // provider -> accountID
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Type == "" {
|
||||
req.Type = "market"
|
||||
}
|
||||
if req.TimeInForce == "" {
|
||||
req.TimeInForce = "day"
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
order, err := s.sor.SmartOrder(r.Context(), req.Accounts, &types.CreateOrderRequest{
|
||||
Symbol: req.Symbol,
|
||||
Qty: req.Qty,
|
||||
Side: req.Side,
|
||||
Type: req.Type,
|
||||
TimeInForce: req.TimeInForce,
|
||||
LimitPrice: req.LimitPrice,
|
||||
})
|
||||
latency := time.Since(start)
|
||||
|
||||
status := "success"
|
||||
var providerUsed string
|
||||
if err != nil {
|
||||
status = "failure"
|
||||
} else {
|
||||
providerUsed = order.Provider
|
||||
}
|
||||
|
||||
s.auditLog.RecordOrder(r.Context(), audit.ActionRouteDecision,
|
||||
providerUsed, "", req.Symbol, req.Side, req.Qty,
|
||||
"", "smart_route", status, latency, err)
|
||||
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, order)
|
||||
}
|
||||
|
||||
func (s *Server) handleExecuteSplit(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Qty string `json:"qty"`
|
||||
Side string `json:"side"`
|
||||
Accounts map[string]string `json:"accounts"` // provider -> accountID
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := s.sor.BuildSplitPlan(r.Context(), req.Symbol, req.Side, req.Qty)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.sor.ExecuteSplitPlan(r.Context(), plan, req.Accounts)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.auditLog.Record(audit.Entry{
|
||||
Action: audit.ActionSplitExecute,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
Qty: req.Qty,
|
||||
Algorithm: "split",
|
||||
Status: result.Status,
|
||||
Metadata: map[string]interface{}{
|
||||
"plan_id": result.PlanID,
|
||||
"vwap": result.VWAP,
|
||||
"total_fees": result.TotalFees,
|
||||
"legs": len(result.Legs),
|
||||
},
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleAggregatedAssets(w http.ResponseWriter, r *http.Request) {
|
||||
assets, err := s.sor.AggregatedAssets(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, assets)
|
||||
}
|
||||
|
||||
// --- Market Data Handlers ---
|
||||
|
||||
func (s *Server) handleGetSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
snap, err := p.GetSnapshot(r.Context(), chi.URLParam(r, "symbol"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, snap)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
syms := r.URL.Query().Get("symbols")
|
||||
if syms == "" {
|
||||
writeError(w, http.StatusBadRequest, "symbols query param required")
|
||||
return
|
||||
}
|
||||
snaps, err := p.GetSnapshots(r.Context(), strings.Split(syms, ","))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, snaps)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetBars(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
timeframe := q.Get("timeframe")
|
||||
if timeframe == "" {
|
||||
timeframe = "1Day"
|
||||
}
|
||||
limit := 0
|
||||
if l := q.Get("limit"); l != "" {
|
||||
limit, _ = strconv.Atoi(l)
|
||||
}
|
||||
bars, err := p.GetBars(r.Context(), chi.URLParam(r, "symbol"), timeframe, q.Get("start"), q.Get("end"), limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, bars)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetLatestTrades(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
syms := r.URL.Query().Get("symbols")
|
||||
if syms == "" {
|
||||
writeError(w, http.StatusBadRequest, "symbols query param required")
|
||||
return
|
||||
}
|
||||
trades, err := p.GetLatestTrades(r.Context(), strings.Split(syms, ","))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, trades)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetLatestQuotes(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
syms := r.URL.Query().Get("symbols")
|
||||
if syms == "" {
|
||||
writeError(w, http.StatusBadRequest, "symbols query param required")
|
||||
return
|
||||
}
|
||||
quotes, err := p.GetLatestQuotes(r.Context(), strings.Split(syms, ","))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, quotes)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetClock(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
clock, err := p.GetClock(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, clock)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.getProvider(r)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
days, err := p.GetCalendar(r.Context(), q.Get("start"), q.Get("end"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, days)
|
||||
}
|
||||
|
||||
// --- Consolidated BBO ---
|
||||
|
||||
func (s *Server) handleGetBBO(w http.ResponseWriter, r *http.Request) {
|
||||
symbol := chi.URLParam(r, "symbol")
|
||||
t, err := s.feed.GetTicker(symbol)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetBBOPair(w http.ResponseWriter, r *http.Request) {
|
||||
symbol := chi.URLParam(r, "symbol") + "/" + chi.URLParam(r, "quote")
|
||||
t, err := s.feed.GetTicker(symbol)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
// --- Risk & Audit ---
|
||||
|
||||
func (s *Server) handleRiskCheck(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
result := s.riskEng.PreTradeCheck(risk.CheckRequest{
|
||||
Provider: q.Get("provider"),
|
||||
AccountID: q.Get("account_id"),
|
||||
Symbol: q.Get("symbol"),
|
||||
Side: q.Get("side"),
|
||||
Qty: q.Get("qty"),
|
||||
Price: q.Get("price"),
|
||||
OrderType: q.Get("type"),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) handleAuditQuery(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
filter := audit.Filter{
|
||||
Action: audit.Action(q.Get("action")),
|
||||
Provider: q.Get("provider"),
|
||||
AccountID: q.Get("account_id"),
|
||||
Symbol: q.Get("symbol"),
|
||||
}
|
||||
if since := q.Get("since"); since != "" {
|
||||
filter.Since, _ = time.Parse(time.RFC3339, since)
|
||||
}
|
||||
if until := q.Get("until"); until != "" {
|
||||
filter.Until, _ = time.Parse(time.RFC3339, until)
|
||||
}
|
||||
entries := s.auditLog.Query(filter)
|
||||
writeJSON(w, http.StatusOK, entries)
|
||||
}
|
||||
|
||||
func (s *Server) handleAuditStats(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.auditLog.Stats())
|
||||
}
|
||||
|
||||
func (s *Server) handleAuditExport(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := s.auditLog.Export()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=audit_export.json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Log provides an append-only audit trail for all trading operations.
|
||||
// Required for institutional compliance (SEC, FINRA, MiFID II).
|
||||
type Log struct {
|
||||
mu sync.Mutex
|
||||
entries []Entry
|
||||
hooks []Hook
|
||||
}
|
||||
|
||||
// Entry is a single audit log record.
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Action Action `json:"action"`
|
||||
Provider string `json:"provider"`
|
||||
AccountID string `json:"account_id"`
|
||||
Symbol string `json:"symbol,omitempty"`
|
||||
Side string `json:"side,omitempty"`
|
||||
Qty string `json:"qty,omitempty"`
|
||||
Price string `json:"price,omitempty"`
|
||||
OrderID string `json:"order_id,omitempty"`
|
||||
Algorithm string `json:"algorithm,omitempty"`
|
||||
Status string `json:"status"` // success, failure, pending
|
||||
Latency time.Duration `json:"latency_ns"` // execution latency
|
||||
Error string `json:"error,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
ClientIP string `json:"client_ip,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
}
|
||||
|
||||
// Action categorizes audit events.
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionOrderCreate Action = "order.create"
|
||||
ActionOrderCancel Action = "order.cancel"
|
||||
ActionOrderFill Action = "order.fill"
|
||||
ActionOrderReject Action = "order.reject"
|
||||
ActionTransfer Action = "transfer.create"
|
||||
ActionAccountCreate Action = "account.create"
|
||||
ActionAccountRead Action = "account.read"
|
||||
ActionRouteDecision Action = "route.decision"
|
||||
ActionSplitPlan Action = "route.split_plan"
|
||||
ActionSplitExecute Action = "route.split_execute"
|
||||
ActionMarketData Action = "market_data.query"
|
||||
ActionAuth Action = "auth"
|
||||
ActionError Action = "error"
|
||||
)
|
||||
|
||||
// Hook is called for every audit entry (e.g., for external logging).
|
||||
type Hook func(Entry)
|
||||
|
||||
func NewLog() *Log {
|
||||
return &Log{
|
||||
entries: make([]Entry, 0, 1024),
|
||||
}
|
||||
}
|
||||
|
||||
// AddHook registers a callback for all audit entries.
|
||||
func (l *Log) AddHook(h Hook) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.hooks = append(l.hooks, h)
|
||||
}
|
||||
|
||||
// Record adds an audit entry.
|
||||
func (l *Log) Record(e Entry) {
|
||||
if e.Timestamp.IsZero() {
|
||||
e.Timestamp = time.Now().UTC()
|
||||
}
|
||||
if e.ID == "" {
|
||||
e.ID = generateID()
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
l.entries = append(l.entries, e)
|
||||
hooks := make([]Hook, len(l.hooks))
|
||||
copy(hooks, l.hooks)
|
||||
l.mu.Unlock()
|
||||
|
||||
for _, h := range hooks {
|
||||
h(e)
|
||||
}
|
||||
}
|
||||
|
||||
// RecordOrder is a convenience for order audit entries.
|
||||
func (l *Log) RecordOrder(ctx context.Context, action Action, provider, accountID, symbol, side, qty, orderID, algo, status string, latency time.Duration, err error) {
|
||||
e := Entry{
|
||||
Action: action,
|
||||
Provider: provider,
|
||||
AccountID: accountID,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Qty: qty,
|
||||
OrderID: orderID,
|
||||
Algorithm: algo,
|
||||
Status: status,
|
||||
Latency: latency,
|
||||
}
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
e.Status = "failure"
|
||||
}
|
||||
l.Record(e)
|
||||
}
|
||||
|
||||
// Query returns audit entries matching the filter.
|
||||
func (l *Log) Query(filter Filter) []Entry {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var results []Entry
|
||||
for _, e := range l.entries {
|
||||
if filter.Matches(e) {
|
||||
results = append(results, e)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// Filter for querying audit entries.
|
||||
type Filter struct {
|
||||
Action Action `json:"action,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
AccountID string `json:"account_id,omitempty"`
|
||||
Symbol string `json:"symbol,omitempty"`
|
||||
Since time.Time `json:"since,omitempty"`
|
||||
Until time.Time `json:"until,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
func (f Filter) Matches(e Entry) bool {
|
||||
if f.Action != "" && e.Action != f.Action {
|
||||
return false
|
||||
}
|
||||
if f.Provider != "" && e.Provider != f.Provider {
|
||||
return false
|
||||
}
|
||||
if f.AccountID != "" && e.AccountID != f.AccountID {
|
||||
return false
|
||||
}
|
||||
if f.Symbol != "" && e.Symbol != f.Symbol {
|
||||
return false
|
||||
}
|
||||
if !f.Since.IsZero() && e.Timestamp.Before(f.Since) {
|
||||
return false
|
||||
}
|
||||
if !f.Until.IsZero() && e.Timestamp.After(f.Until) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Export returns all entries as JSON for compliance reporting.
|
||||
func (l *Log) Export() ([]byte, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return json.Marshal(l.entries)
|
||||
}
|
||||
|
||||
// Stats returns aggregate statistics for the audit log.
|
||||
func (l *Log) Stats() map[string]interface{} {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
actionCounts := make(map[Action]int)
|
||||
providerCounts := make(map[string]int)
|
||||
statusCounts := make(map[string]int)
|
||||
var totalLatency time.Duration
|
||||
orderCount := 0
|
||||
|
||||
for _, e := range l.entries {
|
||||
actionCounts[e.Action]++
|
||||
if e.Provider != "" {
|
||||
providerCounts[e.Provider]++
|
||||
}
|
||||
statusCounts[e.Status]++
|
||||
if e.Action == ActionOrderCreate || e.Action == ActionOrderFill {
|
||||
totalLatency += e.Latency
|
||||
orderCount++
|
||||
}
|
||||
}
|
||||
|
||||
var avgLatency time.Duration
|
||||
if orderCount > 0 {
|
||||
avgLatency = totalLatency / time.Duration(orderCount)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_entries": len(l.entries),
|
||||
"actions": actionCounts,
|
||||
"providers": providerCounts,
|
||||
"statuses": statusCounts,
|
||||
"avg_order_latency": avgLatency.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
return time.Now().UTC().Format("20060102150405.000000")
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIKey represents an authenticated API client.
|
||||
type APIKey struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"` // human-readable name
|
||||
OrgID string `json:"org_id"` // organization
|
||||
Permissions []string `json:"permissions"` // read, trade, admin
|
||||
RateLimit int `json:"rate_limit"` // requests per minute
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// Store holds API keys. In production, back this with a database.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]*APIKey // key -> APIKey
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{keys: make(map[string]*APIKey)}
|
||||
}
|
||||
|
||||
// Add registers an API key.
|
||||
func (s *Store) Add(k *APIKey) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.keys[k.Key] = k
|
||||
}
|
||||
|
||||
// Validate checks an API key and returns the associated client.
|
||||
func (s *Store) Validate(key string) (*APIKey, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for storedKey, ak := range s.keys {
|
||||
if subtle.ConstantTimeCompare([]byte(key), []byte(storedKey)) == 1 {
|
||||
if ak.ExpiresAt != nil && time.Now().After(*ak.ExpiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
return ak, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// HasPermission checks if an API key has a specific permission.
|
||||
func (ak *APIKey) HasPermission(perm string) bool {
|
||||
for _, p := range ak.Permissions {
|
||||
if p == perm || p == "admin" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Middleware returns an HTTP middleware that validates API keys.
|
||||
// Keys can be passed as:
|
||||
// - Authorization: Bearer <key>
|
||||
// - X-API-Key: <key>
|
||||
func Middleware(store *Store) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip auth for health check
|
||||
if r.URL.Path == "/healthz" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
key := extractAPIKey(r)
|
||||
if key == "" {
|
||||
// Allow unauthenticated access in dev mode
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ak, valid := store.Validate(key)
|
||||
if !valid {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"invalid or expired API key"}`))
|
||||
return
|
||||
}
|
||||
|
||||
// Store API key info in request context via header (simple approach)
|
||||
r.Header.Set("X-Org-ID", ak.OrgID)
|
||||
r.Header.Set("X-API-Key-Name", ak.Name)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequirePermission returns middleware that checks for a specific permission.
|
||||
func RequirePermission(store *Store, perm string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := extractAPIKey(r)
|
||||
if key == "" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
ak, valid := store.Validate(key)
|
||||
if !valid || !ak.HasPermission(perm) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"error":"insufficient permissions"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func extractAPIKey(r *http.Request) string {
|
||||
// Check Authorization header
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
return strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
// Check X-API-Key header
|
||||
if key := r.Header.Get("X-API-Key"); key != "" {
|
||||
return key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package marketdata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
// Feed aggregates real-time market data across all providers.
|
||||
// It maintains a consolidated order book and best bid/offer (BBO)
|
||||
// for smart order routing decisions.
|
||||
type Feed struct {
|
||||
mu sync.RWMutex
|
||||
books map[string]*ConsolidatedBook // symbol -> consolidated book
|
||||
tickers map[string]*Ticker // symbol -> latest ticker
|
||||
subs map[string][]chan *Ticker // symbol -> subscriber channels
|
||||
subsMu sync.RWMutex
|
||||
}
|
||||
|
||||
// ConsolidatedBook aggregates order book depth across providers.
|
||||
type ConsolidatedBook struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Bids []PriceLevel `json:"bids"` // sorted best (highest) first
|
||||
Asks []PriceLevel `json:"asks"` // sorted best (lowest) first
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// PriceLevel is a single price/quantity level with provider attribution.
|
||||
type PriceLevel struct {
|
||||
Provider string `json:"provider"`
|
||||
Price float64 `json:"price"`
|
||||
Size float64 `json:"size"`
|
||||
}
|
||||
|
||||
// Ticker is a consolidated real-time price.
|
||||
type Ticker struct {
|
||||
Symbol string `json:"symbol"`
|
||||
BestBid float64 `json:"best_bid"`
|
||||
BestAsk float64 `json:"best_ask"`
|
||||
BestBidProvider string `json:"best_bid_provider"`
|
||||
BestAskProvider string `json:"best_ask_provider"`
|
||||
Spread float64 `json:"spread"`
|
||||
SpreadBps float64 `json:"spread_bps"` // basis points
|
||||
Last float64 `json:"last"`
|
||||
Volume24h float64 `json:"volume_24h,omitempty"`
|
||||
Sources map[string]*Quote `json:"sources"` // per-provider quotes
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Quote is a single provider's quote.
|
||||
type Quote struct {
|
||||
Provider string `json:"provider"`
|
||||
BidPrice float64 `json:"bid_price"`
|
||||
BidSize float64 `json:"bid_size"`
|
||||
AskPrice float64 `json:"ask_price"`
|
||||
AskSize float64 `json:"ask_size"`
|
||||
Last float64 `json:"last"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func NewFeed() *Feed {
|
||||
return &Feed{
|
||||
books: make(map[string]*ConsolidatedBook),
|
||||
tickers: make(map[string]*Ticker),
|
||||
subs: make(map[string][]chan *Ticker),
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateQuote updates a provider's quote for a symbol and recalculates BBO.
|
||||
func (f *Feed) UpdateQuote(symbol, provider string, bid, ask, bidSize, askSize, last float64) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
ticker, ok := f.tickers[symbol]
|
||||
if !ok {
|
||||
ticker = &Ticker{
|
||||
Symbol: symbol,
|
||||
Sources: make(map[string]*Quote),
|
||||
}
|
||||
f.tickers[symbol] = ticker
|
||||
}
|
||||
|
||||
ticker.Sources[provider] = &Quote{
|
||||
Provider: provider,
|
||||
BidPrice: bid,
|
||||
BidSize: bidSize,
|
||||
AskPrice: ask,
|
||||
AskSize: askSize,
|
||||
Last: last,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Recalculate BBO across all providers
|
||||
var bestBid, bestAsk float64
|
||||
var bestBidProv, bestAskProv string
|
||||
first := true
|
||||
for prov, q := range ticker.Sources {
|
||||
if q.BidPrice > bestBid {
|
||||
bestBid = q.BidPrice
|
||||
bestBidProv = prov
|
||||
}
|
||||
if first || (q.AskPrice > 0 && q.AskPrice < bestAsk) {
|
||||
bestAsk = q.AskPrice
|
||||
bestAskProv = prov
|
||||
first = false
|
||||
}
|
||||
}
|
||||
|
||||
ticker.BestBid = bestBid
|
||||
ticker.BestAsk = bestAsk
|
||||
ticker.BestBidProvider = bestBidProv
|
||||
ticker.BestAskProvider = bestAskProv
|
||||
if bestAsk > 0 {
|
||||
ticker.Spread = bestAsk - bestBid
|
||||
mid := (bestAsk + bestBid) / 2
|
||||
if mid > 0 {
|
||||
ticker.SpreadBps = (ticker.Spread / mid) * 10000
|
||||
}
|
||||
}
|
||||
if last > 0 {
|
||||
ticker.Last = last
|
||||
}
|
||||
ticker.UpdatedAt = time.Now()
|
||||
|
||||
// Notify subscribers
|
||||
f.subsMu.RLock()
|
||||
for _, ch := range f.subs[symbol] {
|
||||
select {
|
||||
case ch <- ticker:
|
||||
default: // non-blocking, drop if slow
|
||||
}
|
||||
}
|
||||
f.subsMu.RUnlock()
|
||||
}
|
||||
|
||||
// GetTicker returns the consolidated ticker for a symbol.
|
||||
func (f *Feed) GetTicker(symbol string) (*Ticker, error) {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
t, ok := f.tickers[symbol]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no data for %s", symbol)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// GetBBO returns best bid/offer with provider attribution.
|
||||
func (f *Feed) GetBBO(symbol string) (bestBid, bestAsk float64, bidProvider, askProvider string, err error) {
|
||||
t, err := f.GetTicker(symbol)
|
||||
if err != nil {
|
||||
return 0, 0, "", "", err
|
||||
}
|
||||
return t.BestBid, t.BestAsk, t.BestBidProvider, t.BestAskProvider, nil
|
||||
}
|
||||
|
||||
// Subscribe returns a channel that receives ticker updates for a symbol.
|
||||
func (f *Feed) Subscribe(symbol string) (<-chan *Ticker, func()) {
|
||||
ch := make(chan *Ticker, 16)
|
||||
f.subsMu.Lock()
|
||||
f.subs[symbol] = append(f.subs[symbol], ch)
|
||||
f.subsMu.Unlock()
|
||||
|
||||
unsubscribe := func() {
|
||||
f.subsMu.Lock()
|
||||
defer f.subsMu.Unlock()
|
||||
subs := f.subs[symbol]
|
||||
for i, s := range subs {
|
||||
if s == ch {
|
||||
f.subs[symbol] = append(subs[:i], subs[i+1:]...)
|
||||
close(ch)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return ch, unsubscribe
|
||||
}
|
||||
|
||||
// PollSnapshots polls all providers for market data snapshots.
|
||||
// This is used when WebSocket feeds aren't available.
|
||||
func (f *Feed) PollSnapshots(ctx context.Context, symbols []string, getSnapshot func(ctx context.Context, symbol string) (map[string]*types.MarketSnapshot, error)) {
|
||||
for _, sym := range symbols {
|
||||
snaps, err := getSnapshot(ctx, sym)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for prov, snap := range snaps {
|
||||
var bid, ask, bidSz, askSz, last float64
|
||||
if snap.LatestQuote != nil {
|
||||
bid = snap.LatestQuote.BidPrice
|
||||
ask = snap.LatestQuote.AskPrice
|
||||
bidSz = snap.LatestQuote.BidSize
|
||||
askSz = snap.LatestQuote.AskSize
|
||||
}
|
||||
if snap.LatestTrade != nil {
|
||||
last = snap.LatestTrade.Price
|
||||
}
|
||||
f.UpdateQuote(sym, prov, bid, ask, bidSz, askSz, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AllTickers returns all current tickers.
|
||||
func (f *Feed) AllTickers() map[string]*Ticker {
|
||||
f.mu.RLock()
|
||||
defer f.mu.RUnlock()
|
||||
result := make(map[string]*Ticker, len(f.tickers))
|
||||
for k, v := range f.tickers {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,896 @@
|
||||
package alpaca
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
const (
|
||||
SandboxURL = "https://broker-api.sandbox.alpaca.markets"
|
||||
ProductionURL = "https://broker-api.alpaca.markets"
|
||||
DataURL = "https://data.alpaca.markets"
|
||||
DataSandboxURL = "https://data.sandbox.alpaca.markets"
|
||||
)
|
||||
|
||||
// Config for the Alpaca provider.
|
||||
type Config struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
APISecret string `json:"api_secret"`
|
||||
}
|
||||
|
||||
// Provider implements the broker Provider interface for Alpaca.
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
dataURL string
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = SandboxURL
|
||||
}
|
||||
dataURL := DataURL
|
||||
if strings.Contains(cfg.BaseURL, "sandbox") {
|
||||
dataURL = DataSandboxURL
|
||||
}
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
dataURL: dataURL,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "alpaca" }
|
||||
|
||||
// --- HTTP helpers ---
|
||||
|
||||
func (p *Provider) do(ctx context.Context, method, path string, body interface{}) ([]byte, int, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.SetBasicAuth(p.cfg.APIKey, p.cfg.APISecret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
var apiErr struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if json.Unmarshal(data, &apiErr) == nil && apiErr.Message != "" {
|
||||
return nil, resp.StatusCode, fmt.Errorf("alpaca %d: %s", apiErr.Code, apiErr.Message)
|
||||
}
|
||||
return nil, resp.StatusCode, fmt.Errorf("alpaca %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func decode[T any](data []byte) (*T, error) {
|
||||
var v T
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func decodeSlice[T any](data []byte) ([]*T, error) {
|
||||
var v []T
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ptrs := make([]*T, len(v))
|
||||
for i := range v {
|
||||
ptrs[i] = &v[i]
|
||||
}
|
||||
return ptrs, nil
|
||||
}
|
||||
|
||||
// --- Accounts ---
|
||||
|
||||
type alpacaAccount struct {
|
||||
ID string `json:"id"`
|
||||
AccountNumber string `json:"account_number"`
|
||||
Status string `json:"status"`
|
||||
Currency string `json:"currency"`
|
||||
AccountType string `json:"account_type"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Contact *struct {
|
||||
EmailAddress string `json:"email_address"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
StreetAddress []string `json:"street_address"`
|
||||
City string `json:"city"`
|
||||
State string `json:"state"`
|
||||
PostalCode string `json:"postal_code"`
|
||||
Country string `json:"country"`
|
||||
} `json:"contact"`
|
||||
Identity *struct {
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
DateOfBirth string `json:"date_of_birth"`
|
||||
TaxIDType string `json:"tax_id_type"`
|
||||
CountryOfTaxResidence string `json:"country_of_tax_residence"`
|
||||
} `json:"identity"`
|
||||
EnabledAssets []string `json:"enabled_assets"`
|
||||
}
|
||||
|
||||
func (a *alpacaAccount) toUnified() *types.Account {
|
||||
acct := &types.Account{
|
||||
Provider: "alpaca",
|
||||
ProviderID: a.ID,
|
||||
AccountNumber: a.AccountNumber,
|
||||
Status: a.Status,
|
||||
Currency: a.Currency,
|
||||
AccountType: a.AccountType,
|
||||
EnabledAssets: a.EnabledAssets,
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, a.CreatedAt); err == nil {
|
||||
acct.CreatedAt = t
|
||||
}
|
||||
if a.Identity != nil {
|
||||
acct.Identity = &types.Identity{
|
||||
GivenName: a.Identity.GivenName,
|
||||
FamilyName: a.Identity.FamilyName,
|
||||
DateOfBirth: a.Identity.DateOfBirth,
|
||||
TaxIDType: a.Identity.TaxIDType,
|
||||
CountryOfTax: a.Identity.CountryOfTaxResidence,
|
||||
}
|
||||
}
|
||||
if a.Contact != nil {
|
||||
acct.Contact = &types.Contact{
|
||||
Email: a.Contact.EmailAddress,
|
||||
Phone: a.Contact.PhoneNumber,
|
||||
Street: a.Contact.StreetAddress,
|
||||
City: a.Contact.City,
|
||||
State: a.Contact.State,
|
||||
PostalCode: a.Contact.PostalCode,
|
||||
Country: a.Contact.Country,
|
||||
}
|
||||
}
|
||||
return acct
|
||||
}
|
||||
|
||||
func (p *Provider) CreateAccount(ctx context.Context, req *types.CreateAccountRequest) (*types.Account, error) {
|
||||
body := map[string]interface{}{
|
||||
"contact": map[string]interface{}{
|
||||
"email_address": req.Contact.Email,
|
||||
"phone_number": req.Contact.Phone,
|
||||
"street_address": req.Contact.Street,
|
||||
"city": req.Contact.City,
|
||||
"state": req.Contact.State,
|
||||
"postal_code": req.Contact.PostalCode,
|
||||
"country": req.Contact.Country,
|
||||
},
|
||||
"identity": map[string]interface{}{
|
||||
"given_name": req.Identity.GivenName,
|
||||
"family_name": req.Identity.FamilyName,
|
||||
"date_of_birth": req.Identity.DateOfBirth,
|
||||
"tax_id": req.Identity.TaxID,
|
||||
"tax_id_type": req.Identity.TaxIDType,
|
||||
"country_of_tax_residence": req.Identity.CountryOfTax,
|
||||
"funding_source": []string{"employment_income"},
|
||||
},
|
||||
"disclosures": map[string]interface{}{
|
||||
"is_control_person": false,
|
||||
"is_affiliated_exchange_or_finra": false,
|
||||
"is_politically_exposed": false,
|
||||
"immediate_family_exposed": false,
|
||||
},
|
||||
"agreements": []map[string]interface{}{
|
||||
{"agreement": "margin_agreement", "signed_at": time.Now().UTC().Format(time.RFC3339), "ip_address": "0.0.0.0"},
|
||||
{"agreement": "account_agreement", "signed_at": time.Now().UTC().Format(time.RFC3339), "ip_address": "0.0.0.0"},
|
||||
{"agreement": "customer_agreement", "signed_at": time.Now().UTC().Format(time.RFC3339), "ip_address": "0.0.0.0"},
|
||||
{"agreement": "crypto_agreement", "signed_at": time.Now().UTC().Format(time.RFC3339), "ip_address": "0.0.0.0"},
|
||||
},
|
||||
}
|
||||
if len(req.EnabledAssets) > 0 {
|
||||
body["enabled_assets"] = req.EnabledAssets
|
||||
}
|
||||
|
||||
data, _, err := p.do(ctx, http.MethodPost, "/v1/accounts", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := decode[alpacaAccount](data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw.toUnified(), nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, providerAccountID string) (*types.Account, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/v1/accounts/"+providerAccountID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := decode[alpacaAccount](data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw.toUnified(), nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/v1/accounts", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []alpacaAccount
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accts := make([]*types.Account, len(raw))
|
||||
for i := range raw {
|
||||
accts[i] = raw[i].toUnified()
|
||||
}
|
||||
return accts, nil
|
||||
}
|
||||
|
||||
// --- Portfolio & Positions ---
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, providerAccountID string) (*types.Portfolio, error) {
|
||||
// Fetch trading account
|
||||
tData, _, err := p.do(ctx, http.MethodGet, "/v1/trading/accounts/"+providerAccountID+"/account", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ta struct {
|
||||
Cash string `json:"cash"`
|
||||
Equity string `json:"equity"`
|
||||
BuyingPower string `json:"buying_power"`
|
||||
PortfolioValue string `json:"portfolio_value"`
|
||||
}
|
||||
if err := json.Unmarshal(tData, &ta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch positions
|
||||
pData, _, err := p.do(ctx, http.MethodGet, "/v1/trading/accounts/"+providerAccountID+"/positions", nil)
|
||||
if err != nil {
|
||||
return nil, 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(pData, &rawPositions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
positions := make([]types.Position, len(rawPositions))
|
||||
for i, rp := range rawPositions {
|
||||
positions[i] = types.Position{
|
||||
Symbol: rp.Symbol,
|
||||
Qty: rp.Qty,
|
||||
AvgEntryPrice: rp.AvgEntryPrice,
|
||||
MarketValue: rp.MarketValue,
|
||||
CurrentPrice: rp.CurrentPrice,
|
||||
UnrealizedPL: rp.UnrealizedPL,
|
||||
Side: rp.Side,
|
||||
AssetClass: rp.AssetClass,
|
||||
}
|
||||
}
|
||||
|
||||
return &types.Portfolio{
|
||||
AccountID: providerAccountID,
|
||||
Cash: ta.Cash,
|
||||
Equity: ta.Equity,
|
||||
BuyingPower: ta.BuyingPower,
|
||||
PortfolioValue: ta.PortfolioValue,
|
||||
Positions: positions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Orders ---
|
||||
|
||||
func (p *Provider) CreateOrder(ctx context.Context, providerAccountID string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
body := map[string]interface{}{
|
||||
"symbol": req.Symbol,
|
||||
"side": req.Side,
|
||||
"type": req.Type,
|
||||
"time_in_force": req.TimeInForce,
|
||||
}
|
||||
if req.Qty != "" {
|
||||
body["qty"] = req.Qty
|
||||
}
|
||||
if req.Notional != "" {
|
||||
body["notional"] = req.Notional
|
||||
}
|
||||
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/"+providerAccountID+"/orders", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.parseOrder(data)
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, providerAccountID string) ([]*types.Order, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/v1/trading/accounts/"+providerAccountID+"/orders", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orders := make([]*types.Order, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
o, err := p.parseOrder(r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
orders = append(orders, o)
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, providerAccountID, providerOrderID string) (*types.Order, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/v1/trading/accounts/"+providerAccountID+"/orders/"+providerOrderID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.parseOrder(data)
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(ctx context.Context, providerAccountID, providerOrderID string) error {
|
||||
_, _, err := p.do(ctx, http.MethodDelete, "/v1/trading/accounts/"+providerAccountID+"/orders/"+providerOrderID, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *Provider) parseOrder(data []byte) (*types.Order, error) {
|
||||
var raw struct {
|
||||
ID string `json:"id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Qty string `json:"qty"`
|
||||
Notional string `json:"notional"`
|
||||
Side string `json:"side"`
|
||||
Type string `json:"type"`
|
||||
TimeInForce string `json:"time_in_force"`
|
||||
LimitPrice string `json:"limit_price"`
|
||||
StopPrice string `json:"stop_price"`
|
||||
Status string `json:"status"`
|
||||
FilledQty string `json:"filled_qty"`
|
||||
FilledAvgPrice string `json:"filled_avg_price"`
|
||||
AssetClass string `json:"asset_class"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
FilledAt *string `json:"filled_at"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
o := &types.Order{
|
||||
Provider: "alpaca",
|
||||
ProviderID: raw.ID,
|
||||
Symbol: raw.Symbol,
|
||||
Qty: raw.Qty,
|
||||
Notional: raw.Notional,
|
||||
Side: raw.Side,
|
||||
Type: raw.Type,
|
||||
TimeInForce: raw.TimeInForce,
|
||||
LimitPrice: raw.LimitPrice,
|
||||
StopPrice: raw.StopPrice,
|
||||
Status: raw.Status,
|
||||
FilledQty: raw.FilledQty,
|
||||
FilledAvgPrice: raw.FilledAvgPrice,
|
||||
AssetClass: raw.AssetClass,
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, raw.CreatedAt); err == nil {
|
||||
o.CreatedAt = t
|
||||
}
|
||||
if raw.FilledAt != nil {
|
||||
if t, err := time.Parse(time.RFC3339Nano, *raw.FilledAt); err == nil {
|
||||
o.FilledAt = &t
|
||||
}
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// --- Transfers ---
|
||||
|
||||
func (p *Provider) CreateTransfer(ctx context.Context, providerAccountID string, req *types.CreateTransferRequest) (*types.Transfer, error) {
|
||||
body := map[string]interface{}{
|
||||
"transfer_type": req.Type,
|
||||
"amount": req.Amount,
|
||||
"direction": req.Direction,
|
||||
}
|
||||
if req.RelationshipID != "" {
|
||||
body["relationship_id"] = req.RelationshipID
|
||||
}
|
||||
|
||||
data, _, err := p.do(ctx, http.MethodPost, "/v1/accounts/"+providerAccountID+"/transfers", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.parseTransfer(data)
|
||||
}
|
||||
|
||||
func (p *Provider) ListTransfers(ctx context.Context, providerAccountID string) ([]*types.Transfer, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/v1/accounts/"+providerAccountID+"/transfers", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transfers := make([]*types.Transfer, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
t, err := p.parseTransfer(r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
transfers = append(transfers, t)
|
||||
}
|
||||
return transfers, nil
|
||||
}
|
||||
|
||||
func (p *Provider) parseTransfer(data []byte) (*types.Transfer, error) {
|
||||
var raw struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"account_id"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Amount string `json:"amount"`
|
||||
Direction string `json:"direction"`
|
||||
Currency string `json:"currency"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := &types.Transfer{
|
||||
Provider: "alpaca",
|
||||
ProviderID: raw.ID,
|
||||
AccountID: raw.AccountID,
|
||||
Type: raw.Type,
|
||||
Direction: raw.Direction,
|
||||
Amount: raw.Amount,
|
||||
Currency: raw.Currency,
|
||||
Status: raw.Status,
|
||||
}
|
||||
if ts, err := time.Parse(time.RFC3339Nano, raw.CreatedAt); err == nil {
|
||||
t.CreatedAt = ts
|
||||
}
|
||||
if ts, err := time.Parse(time.RFC3339Nano, raw.UpdatedAt); err == nil {
|
||||
t.UpdatedAt = ts
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// --- Bank Relationships ---
|
||||
|
||||
func (p *Provider) CreateBankRelationship(ctx context.Context, providerAccountID string, ownerName, accountType, accountNumber, routingNumber string) (*types.BankRelationship, error) {
|
||||
body := map[string]interface{}{
|
||||
"account_owner_name": ownerName,
|
||||
"bank_account_type": accountType,
|
||||
"bank_account_number": accountNumber,
|
||||
"bank_routing_number": routingNumber,
|
||||
}
|
||||
data, _, err := p.do(ctx, http.MethodPost, "/v1/accounts/"+providerAccountID+"/ach_relationships", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.parseBankRelationship(data)
|
||||
}
|
||||
|
||||
func (p *Provider) ListBankRelationships(ctx context.Context, providerAccountID string) ([]*types.BankRelationship, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/v1/accounts/"+providerAccountID+"/ach_relationships", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rels := make([]*types.BankRelationship, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
br, err := p.parseBankRelationship(r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rels = append(rels, br)
|
||||
}
|
||||
return rels, nil
|
||||
}
|
||||
|
||||
func (p *Provider) parseBankRelationship(data []byte) (*types.BankRelationship, error) {
|
||||
var raw struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"account_id"`
|
||||
AccountOwnerName string `json:"account_owner_name"`
|
||||
BankAccountType string `json:"bank_account_type"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &types.BankRelationship{
|
||||
Provider: "alpaca",
|
||||
ProviderID: raw.ID,
|
||||
AccountID: raw.AccountID,
|
||||
AccountOwnerName: raw.AccountOwnerName,
|
||||
BankAccountType: raw.BankAccountType,
|
||||
Status: raw.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Assets ---
|
||||
|
||||
func (p *Provider) ListAssets(ctx context.Context, class string) ([]*types.Asset, error) {
|
||||
path := "/v1/assets?status=active"
|
||||
if class != "" {
|
||||
path += "&asset_class=" + class
|
||||
}
|
||||
data, _, err := p.do(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []struct {
|
||||
ID string `json:"id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
Class string `json:"class"`
|
||||
Exchange string `json:"exchange"`
|
||||
Status string `json:"status"`
|
||||
Tradable bool `json:"tradable"`
|
||||
Fractionable bool `json:"fractionable"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assets := make([]*types.Asset, len(raw))
|
||||
for i, r := range raw {
|
||||
assets[i] = &types.Asset{
|
||||
ID: r.ID,
|
||||
Provider: "alpaca",
|
||||
Symbol: r.Symbol,
|
||||
Name: r.Name,
|
||||
Class: r.Class,
|
||||
Exchange: r.Exchange,
|
||||
Status: r.Status,
|
||||
Tradable: r.Tradable,
|
||||
Fractionable: r.Fractionable,
|
||||
}
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(ctx context.Context, symbolOrID string) (*types.Asset, error) {
|
||||
// Alpaca uses no-slash symbols for API calls (BTC/USD → BTCUSD)
|
||||
apiSymbol := strings.ReplaceAll(symbolOrID, "/", "")
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/v1/assets/"+apiSymbol, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
ID string `json:"id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
Class string `json:"class"`
|
||||
Exchange string `json:"exchange"`
|
||||
Status string `json:"status"`
|
||||
Tradable bool `json:"tradable"`
|
||||
Fractionable bool `json:"fractionable"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &types.Asset{
|
||||
ID: raw.ID,
|
||||
Provider: "alpaca",
|
||||
Symbol: raw.Symbol,
|
||||
Name: raw.Name,
|
||||
Class: raw.Class,
|
||||
Exchange: raw.Exchange,
|
||||
Status: raw.Status,
|
||||
Tradable: raw.Tradable,
|
||||
Fractionable: raw.Fractionable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Market Data (uses data API, sandbox-aware) ---
|
||||
|
||||
func (p *Provider) doData(ctx context.Context, method, path string) ([]byte, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.dataURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.SetBasicAuth(p.cfg.APIKey, p.cfg.APISecret)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, resp.StatusCode, fmt.Errorf("alpaca data %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshot(ctx context.Context, symbol string) (*types.MarketSnapshot, error) {
|
||||
data, _, err := p.doData(ctx, http.MethodGet, "/v2/stocks/"+symbol+"/snapshot")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.parseSnapshot(symbol, data)
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshots(ctx context.Context, symbols []string) (map[string]*types.MarketSnapshot, error) {
|
||||
path := "/v2/stocks/snapshots?symbols=" + strings.Join(symbols, ",")
|
||||
data, _, err := p.doData(ctx, http.MethodGet, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]*types.MarketSnapshot, len(raw))
|
||||
for sym, d := range raw {
|
||||
snap, err := p.parseSnapshot(sym, d)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result[sym] = snap
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) parseSnapshot(symbol string, data []byte) (*types.MarketSnapshot, error) {
|
||||
var raw struct {
|
||||
LatestTrade *struct {
|
||||
T string `json:"t"`
|
||||
P float64 `json:"p"`
|
||||
S float64 `json:"s"`
|
||||
X string `json:"x"`
|
||||
} `json:"latestTrade"`
|
||||
LatestQuote *struct {
|
||||
T string `json:"t"`
|
||||
BP float64 `json:"bp"`
|
||||
BS float64 `json:"bs"`
|
||||
AP float64 `json:"ap"`
|
||||
AS float64 `json:"as"`
|
||||
} `json:"latestQuote"`
|
||||
MinuteBar *struct {
|
||||
T string `json:"t"`
|
||||
O float64 `json:"o"`
|
||||
H float64 `json:"h"`
|
||||
L float64 `json:"l"`
|
||||
C float64 `json:"c"`
|
||||
V float64 `json:"v"`
|
||||
VW float64 `json:"vw"`
|
||||
N int `json:"n"`
|
||||
} `json:"minuteBar"`
|
||||
DailyBar *struct {
|
||||
T string `json:"t"`
|
||||
O float64 `json:"o"`
|
||||
H float64 `json:"h"`
|
||||
L float64 `json:"l"`
|
||||
C float64 `json:"c"`
|
||||
V float64 `json:"v"`
|
||||
VW float64 `json:"vw"`
|
||||
N int `json:"n"`
|
||||
} `json:"dailyBar"`
|
||||
PrevDailyBar *struct {
|
||||
T string `json:"t"`
|
||||
O float64 `json:"o"`
|
||||
H float64 `json:"h"`
|
||||
L float64 `json:"l"`
|
||||
C float64 `json:"c"`
|
||||
V float64 `json:"v"`
|
||||
VW float64 `json:"vw"`
|
||||
N int `json:"n"`
|
||||
} `json:"prevDailyBar"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snap := &types.MarketSnapshot{Symbol: symbol}
|
||||
if raw.LatestTrade != nil {
|
||||
snap.LatestTrade = &types.Trade{Timestamp: raw.LatestTrade.T, Price: raw.LatestTrade.P, Size: raw.LatestTrade.S, Exchange: raw.LatestTrade.X}
|
||||
}
|
||||
if raw.LatestQuote != nil {
|
||||
snap.LatestQuote = &types.Quote{Timestamp: raw.LatestQuote.T, BidPrice: raw.LatestQuote.BP, BidSize: raw.LatestQuote.BS, AskPrice: raw.LatestQuote.AP, AskSize: raw.LatestQuote.AS}
|
||||
}
|
||||
if raw.MinuteBar != nil {
|
||||
snap.MinuteBar = &types.Bar{Timestamp: raw.MinuteBar.T, Open: raw.MinuteBar.O, High: raw.MinuteBar.H, Low: raw.MinuteBar.L, Close: raw.MinuteBar.C, Volume: raw.MinuteBar.V, VWAP: raw.MinuteBar.VW, TradeCount: raw.MinuteBar.N}
|
||||
}
|
||||
if raw.DailyBar != nil {
|
||||
snap.DailyBar = &types.Bar{Timestamp: raw.DailyBar.T, Open: raw.DailyBar.O, High: raw.DailyBar.H, Low: raw.DailyBar.L, Close: raw.DailyBar.C, Volume: raw.DailyBar.V, VWAP: raw.DailyBar.VW, TradeCount: raw.DailyBar.N}
|
||||
}
|
||||
if raw.PrevDailyBar != nil {
|
||||
snap.PrevDailyBar = &types.Bar{Timestamp: raw.PrevDailyBar.T, Open: raw.PrevDailyBar.O, High: raw.PrevDailyBar.H, Low: raw.PrevDailyBar.L, Close: raw.PrevDailyBar.C, Volume: raw.PrevDailyBar.V, VWAP: raw.PrevDailyBar.VW, TradeCount: raw.PrevDailyBar.N}
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetBars(ctx context.Context, symbol, timeframe, start, end string, limit int) ([]*types.Bar, error) {
|
||||
path := "/v2/stocks/" + symbol + "/bars?timeframe=" + timeframe
|
||||
if start != "" {
|
||||
path += "&start=" + start
|
||||
}
|
||||
if end != "" {
|
||||
path += "&end=" + end
|
||||
}
|
||||
if limit > 0 {
|
||||
path += "&limit=" + strconv.Itoa(limit)
|
||||
}
|
||||
data, _, err := p.doData(ctx, http.MethodGet, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Bars []struct {
|
||||
T string `json:"t"`
|
||||
O float64 `json:"o"`
|
||||
H float64 `json:"h"`
|
||||
L float64 `json:"l"`
|
||||
C float64 `json:"c"`
|
||||
V float64 `json:"v"`
|
||||
VW float64 `json:"vw"`
|
||||
N int `json:"n"`
|
||||
} `json:"bars"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bars := make([]*types.Bar, len(raw.Bars))
|
||||
for i, b := range raw.Bars {
|
||||
bars[i] = &types.Bar{Timestamp: b.T, Open: b.O, High: b.H, Low: b.L, Close: b.C, Volume: b.V, VWAP: b.VW, TradeCount: b.N}
|
||||
}
|
||||
return bars, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestTrades(ctx context.Context, symbols []string) (map[string]*types.Trade, error) {
|
||||
path := "/v2/stocks/trades/latest?symbols=" + strings.Join(symbols, ",")
|
||||
data, _, err := p.doData(ctx, http.MethodGet, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Trades map[string]struct {
|
||||
T string `json:"t"`
|
||||
P float64 `json:"p"`
|
||||
S float64 `json:"s"`
|
||||
X string `json:"x"`
|
||||
} `json:"trades"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]*types.Trade, len(raw.Trades))
|
||||
for sym, t := range raw.Trades {
|
||||
result[sym] = &types.Trade{Timestamp: t.T, Price: t.P, Size: t.S, Exchange: t.X}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestQuotes(ctx context.Context, symbols []string) (map[string]*types.Quote, error) {
|
||||
path := "/v2/stocks/quotes/latest?symbols=" + strings.Join(symbols, ",")
|
||||
data, _, err := p.doData(ctx, http.MethodGet, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Quotes map[string]struct {
|
||||
T string `json:"t"`
|
||||
BP float64 `json:"bp"`
|
||||
BS float64 `json:"bs"`
|
||||
AP float64 `json:"ap"`
|
||||
AS float64 `json:"as"`
|
||||
} `json:"quotes"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]*types.Quote, len(raw.Quotes))
|
||||
for sym, q := range raw.Quotes {
|
||||
result[sym] = &types.Quote{Timestamp: q.T, BidPrice: q.BP, BidSize: q.BS, AskPrice: q.AP, AskSize: q.AS}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetClock(ctx context.Context) (*types.MarketClock, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/v1/clock", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
IsOpen bool `json:"is_open"`
|
||||
NextOpen string `json:"next_open"`
|
||||
NextClose string `json:"next_close"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &types.MarketClock{
|
||||
Timestamp: raw.Timestamp,
|
||||
IsOpen: raw.IsOpen,
|
||||
NextOpen: raw.NextOpen,
|
||||
NextClose: raw.NextClose,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetCalendar(ctx context.Context, start, end string) ([]*types.MarketCalendarDay, error) {
|
||||
path := "/v1/calendar"
|
||||
sep := "?"
|
||||
if start != "" {
|
||||
path += sep + "start=" + start
|
||||
sep = "&"
|
||||
}
|
||||
if end != "" {
|
||||
path += sep + "end=" + end
|
||||
}
|
||||
data, _, err := p.do(ctx, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []struct {
|
||||
Date string `json:"date"`
|
||||
Open string `json:"open"`
|
||||
Close string `json:"close"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
days := make([]*types.MarketCalendarDay, len(raw))
|
||||
for i, d := range raw {
|
||||
days[i] = &types.MarketCalendarDay{Date: d.Date, Open: d.Open, Close: d.Close}
|
||||
}
|
||||
return days, nil
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
package binance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ProdURL = "https://api.binance.us/api/v3"
|
||||
GlobalURL = "https://api.binance.com/api/v3"
|
||||
SandboxURL = "https://testnet.binance.vision/api/v3"
|
||||
)
|
||||
|
||||
// Config for Binance (US or global).
|
||||
type Config struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
APISecret string `json:"api_secret"`
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = ProdURL
|
||||
}
|
||||
return &Provider{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "binance" }
|
||||
|
||||
func (p *Provider) sign(params string) string {
|
||||
mac := hmac.New(sha256.New, []byte(p.cfg.APISecret))
|
||||
mac.Write([]byte(params))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (p *Provider) doPublic(ctx context.Context, path string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.cfg.BaseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("binance %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *Provider) doSigned(ctx context.Context, method, path string, params string) ([]byte, error) {
|
||||
ts := strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
qstr := params
|
||||
if qstr != "" {
|
||||
qstr += "&"
|
||||
}
|
||||
qstr += "timestamp=" + ts
|
||||
qstr += "&signature=" + p.sign(qstr)
|
||||
|
||||
var reqBody io.Reader
|
||||
url := p.cfg.BaseURL + path
|
||||
if method == http.MethodGet || method == http.MethodDelete {
|
||||
url += "?" + qstr
|
||||
} else {
|
||||
reqBody = bytes.NewReader([]byte(qstr))
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-MBX-APIKEY", p.cfg.APIKey)
|
||||
if method == http.MethodPost {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("binance %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// --- Accounts ---
|
||||
|
||||
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) {
|
||||
return nil, fmt.Errorf("binance: account creation via binance.us")
|
||||
}
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, _ string) (*types.Account, error) {
|
||||
data, err := p.doSigned(ctx, http.MethodGet, "/account", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
AccountType string `json:"accountType"`
|
||||
CanTrade bool `json:"canTrade"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
status := "inactive"
|
||||
if raw.CanTrade {
|
||||
status = "active"
|
||||
}
|
||||
return &types.Account{
|
||||
Provider: "binance", ProviderID: "default",
|
||||
Status: status, Currency: "USD", AccountType: raw.AccountType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
acct, err := p.GetAccount(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*types.Account{acct}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, _ string) (*types.Portfolio, error) {
|
||||
data, err := p.doSigned(ctx, http.MethodGet, "/account", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Balances []struct {
|
||||
Asset string `json:"asset"`
|
||||
Free string `json:"free"`
|
||||
Locked string `json:"locked"`
|
||||
} `json:"balances"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
|
||||
var positions []types.Position
|
||||
var cash string
|
||||
for _, b := range raw.Balances {
|
||||
free, _ := strconv.ParseFloat(b.Free, 64)
|
||||
locked, _ := strconv.ParseFloat(b.Locked, 64)
|
||||
total := free + locked
|
||||
if total <= 0 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(b.Asset, "USD") || strings.EqualFold(b.Asset, "USDT") || strings.EqualFold(b.Asset, "USDC") {
|
||||
cash = fmt.Sprintf("%.2f", total)
|
||||
continue
|
||||
}
|
||||
positions = append(positions, types.Position{
|
||||
Symbol: b.Asset, Qty: fmt.Sprintf("%.8f", total), AssetClass: "crypto",
|
||||
})
|
||||
}
|
||||
return &types.Portfolio{Cash: cash, Positions: positions}, nil
|
||||
}
|
||||
|
||||
// --- Orders ---
|
||||
|
||||
func (p *Provider) CreateOrder(ctx context.Context, _ string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
sym := normalizeSymbol(req.Symbol)
|
||||
params := fmt.Sprintf("symbol=%s&side=%s&type=%s&quantity=%s",
|
||||
sym, strings.ToUpper(req.Side), mapOrderType(req.Type), req.Qty)
|
||||
if req.LimitPrice != "" {
|
||||
params += "&price=" + req.LimitPrice + "&timeInForce=" + mapTIF(req.TimeInForce)
|
||||
}
|
||||
|
||||
data, err := p.doSigned(ctx, http.MethodPost, "/order", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
OrderID int `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Status string `json:"status"`
|
||||
ExecutedQty string `json:"executedQty"`
|
||||
CumQuoteQty string `json:"cummulativeQuoteQty"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
return &types.Order{
|
||||
Provider: "binance", ProviderID: strconv.Itoa(raw.OrderID),
|
||||
Symbol: req.Symbol, Qty: req.Qty, Side: req.Side, Type: req.Type,
|
||||
Status: mapBinanceStatus(raw.Status), FilledQty: raw.ExecutedQty,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, _ string) ([]*types.Order, error) {
|
||||
data, err := p.doSigned(ctx, http.MethodGet, "/openOrders", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []struct {
|
||||
OrderID int `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Qty string `json:"origQty"`
|
||||
Filled string `json:"executedQty"`
|
||||
Price string `json:"price"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
orders := make([]*types.Order, len(raw))
|
||||
for i, o := range raw {
|
||||
orders[i] = &types.Order{
|
||||
Provider: "binance", ProviderID: strconv.Itoa(o.OrderID),
|
||||
Symbol: o.Symbol, Side: strings.ToLower(o.Side), Type: strings.ToLower(o.Type),
|
||||
Status: mapBinanceStatus(o.Status), Qty: o.Qty, FilledQty: o.Filled,
|
||||
}
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, _ string, orderID string) (*types.Order, error) {
|
||||
// Binance requires symbol — use allOrders with orderId
|
||||
data, err := p.doSigned(ctx, http.MethodGet, "/order", "orderId="+orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
OrderID int `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
Status string `json:"status"`
|
||||
Qty string `json:"origQty"`
|
||||
Filled string `json:"executedQty"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
return &types.Order{
|
||||
Provider: "binance", ProviderID: strconv.Itoa(raw.OrderID),
|
||||
Symbol: raw.Symbol, Side: strings.ToLower(raw.Side),
|
||||
Status: mapBinanceStatus(raw.Status), Qty: raw.Qty, FilledQty: raw.Filled,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(ctx context.Context, _ string, orderID string) error {
|
||||
_, err := p.doSigned(ctx, http.MethodDelete, "/order", "orderId="+orderID)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Stubs ---
|
||||
|
||||
func (p *Provider) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) {
|
||||
return nil, fmt.Errorf("binance: transfers via binance.us")
|
||||
}
|
||||
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) {
|
||||
return []*types.Transfer{}, nil
|
||||
}
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) {
|
||||
return nil, fmt.Errorf("binance: bank linking via binance.us")
|
||||
}
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) {
|
||||
return []*types.BankRelationship{}, nil
|
||||
}
|
||||
|
||||
// --- Assets ---
|
||||
|
||||
func (p *Provider) ListAssets(ctx context.Context, _ string) ([]*types.Asset, error) {
|
||||
data, err := p.doPublic(ctx, "/exchangeInfo")
|
||||
if err != nil {
|
||||
return defaultAssets(), nil
|
||||
}
|
||||
var info struct {
|
||||
Symbols []struct {
|
||||
Symbol string `json:"symbol"`
|
||||
BaseAsset string `json:"baseAsset"`
|
||||
QuoteAsset string `json:"quoteAsset"`
|
||||
Status string `json:"status"`
|
||||
} `json:"symbols"`
|
||||
}
|
||||
json.Unmarshal(data, &info)
|
||||
assets := make([]*types.Asset, 0)
|
||||
for _, s := range info.Symbols {
|
||||
if s.QuoteAsset != "USD" && s.QuoteAsset != "USDT" {
|
||||
continue
|
||||
}
|
||||
tradable := s.Status == "TRADING"
|
||||
assets = append(assets, &types.Asset{
|
||||
ID: s.Symbol, Provider: "binance",
|
||||
Symbol: s.BaseAsset + "/" + s.QuoteAsset, Name: s.Symbol,
|
||||
Class: "crypto", Status: "active", Tradable: tradable,
|
||||
})
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(_ context.Context, sym string) (*types.Asset, error) {
|
||||
return &types.Asset{ID: sym, Provider: "binance", Symbol: sym, Class: "crypto", Status: "active", Tradable: true}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshot(ctx context.Context, symbol string) (*types.MarketSnapshot, error) {
|
||||
sym := normalizeSymbol(symbol)
|
||||
data, err := p.doPublic(ctx, "/ticker/24hr?symbol="+sym)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
BidPrice string `json:"bidPrice"`
|
||||
AskPrice string `json:"askPrice"`
|
||||
LastPrice string `json:"lastPrice"`
|
||||
Volume string `json:"volume"`
|
||||
High string `json:"highPrice"`
|
||||
Low string `json:"lowPrice"`
|
||||
Open string `json:"openPrice"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
bid, _ := strconv.ParseFloat(raw.BidPrice, 64)
|
||||
ask, _ := strconv.ParseFloat(raw.AskPrice, 64)
|
||||
last, _ := strconv.ParseFloat(raw.LastPrice, 64)
|
||||
vol, _ := strconv.ParseFloat(raw.Volume, 64)
|
||||
hi, _ := strconv.ParseFloat(raw.High, 64)
|
||||
lo, _ := strconv.ParseFloat(raw.Low, 64)
|
||||
op, _ := strconv.ParseFloat(raw.Open, 64)
|
||||
|
||||
return &types.MarketSnapshot{
|
||||
Symbol: symbol,
|
||||
LatestQuote: &types.Quote{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
BidPrice: bid, AskPrice: ask,
|
||||
},
|
||||
LatestTrade: &types.Trade{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Price: last,
|
||||
},
|
||||
DailyBar: &types.Bar{Open: op, High: hi, Low: lo, Close: last, Volume: vol},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshots(ctx context.Context, symbols []string) (map[string]*types.MarketSnapshot, error) {
|
||||
result := make(map[string]*types.MarketSnapshot)
|
||||
for _, s := range symbols {
|
||||
snap, err := p.GetSnapshot(ctx, s)
|
||||
if err == nil {
|
||||
result[s] = snap
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) {
|
||||
return nil, fmt.Errorf("binance: bars not yet implemented")
|
||||
}
|
||||
func (p *Provider) GetLatestTrades(_ context.Context, _ []string) (map[string]*types.Trade, error) {
|
||||
return nil, fmt.Errorf("binance: not yet implemented")
|
||||
}
|
||||
func (p *Provider) GetLatestQuotes(_ context.Context, _ []string) (map[string]*types.Quote, error) {
|
||||
return nil, fmt.Errorf("binance: not yet implemented")
|
||||
}
|
||||
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) {
|
||||
return &types.MarketClock{Timestamp: time.Now().UTC().Format(time.RFC3339), IsOpen: true}, nil
|
||||
}
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) {
|
||||
return nil, fmt.Errorf("binance: crypto 24/7")
|
||||
}
|
||||
|
||||
func normalizeSymbol(s string) string {
|
||||
s = strings.ToUpper(s)
|
||||
s = strings.ReplaceAll(s, "/", "")
|
||||
s = strings.ReplaceAll(s, "-", "")
|
||||
return s
|
||||
}
|
||||
func mapOrderType(t string) string {
|
||||
switch t {
|
||||
case "market":
|
||||
return "MARKET"
|
||||
case "limit":
|
||||
return "LIMIT"
|
||||
case "stop":
|
||||
return "STOP_LOSS"
|
||||
case "stop_limit":
|
||||
return "STOP_LOSS_LIMIT"
|
||||
default:
|
||||
return "MARKET"
|
||||
}
|
||||
}
|
||||
func mapTIF(t string) string {
|
||||
switch t {
|
||||
case "gtc":
|
||||
return "GTC"
|
||||
case "ioc":
|
||||
return "IOC"
|
||||
case "fok":
|
||||
return "FOK"
|
||||
default:
|
||||
return "GTC"
|
||||
}
|
||||
}
|
||||
func mapBinanceStatus(s string) string {
|
||||
switch s {
|
||||
case "NEW":
|
||||
return "new"
|
||||
case "PARTIALLY_FILLED":
|
||||
return "partially_filled"
|
||||
case "FILLED":
|
||||
return "filled"
|
||||
case "CANCELED":
|
||||
return "cancelled"
|
||||
case "REJECTED":
|
||||
return "rejected"
|
||||
case "EXPIRED":
|
||||
return "expired"
|
||||
default:
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultAssets() []*types.Asset {
|
||||
syms := []string{"BTC", "ETH", "SOL", "AVAX", "DOT", "LINK", "UNI", "LTC", "XRP", "ADA", "DOGE", "MATIC", "ATOM"}
|
||||
out := make([]*types.Asset, len(syms))
|
||||
for i, s := range syms {
|
||||
out[i] = &types.Asset{ID: s + "USD", Provider: "binance", Symbol: s + "/USD", Class: "crypto", Status: "active", Tradable: true}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package bitgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
const (
|
||||
TestURL = "https://app.bitgo-test.com/api/v2"
|
||||
ProdURL = "https://app.bitgo.com/api/v2"
|
||||
)
|
||||
|
||||
// Config for the BitGo provider.
|
||||
type Config struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
AccessToken string `json:"access_token"`
|
||||
Enterprise string `json:"enterprise"` // enterprise ID
|
||||
}
|
||||
|
||||
// Provider implements the broker Provider interface for BitGo.
|
||||
// BitGo is primarily a custody/wallet provider, not a traditional broker.
|
||||
// It supports crypto wallets, transfers, and trading via its Prime product.
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = TestURL
|
||||
}
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "bitgo" }
|
||||
|
||||
func (p *Provider) do(ctx context.Context, method, path string, body interface{}) ([]byte, int, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
reqBody = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+p.cfg.AccessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, resp.StatusCode, fmt.Errorf("bitgo %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// --- Accounts (BitGo enterprises/wallets) ---
|
||||
|
||||
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) {
|
||||
return nil, fmt.Errorf("bitgo: use wallet creation APIs instead")
|
||||
}
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, providerAccountID string) (*types.Account, error) {
|
||||
// In BitGo, "account" maps to enterprise
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/enterprise/"+providerAccountID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ent struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
json.Unmarshal(data, &ent)
|
||||
return &types.Account{
|
||||
Provider: "bitgo",
|
||||
ProviderID: ent.ID,
|
||||
AccountNumber: ent.Name,
|
||||
Status: "active",
|
||||
Currency: "USD",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
// List wallets across all coins
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/wallets", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Wallets []struct {
|
||||
ID string `json:"id"`
|
||||
Coin string `json:"coin"`
|
||||
Label string `json:"label"`
|
||||
} `json:"wallets"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
accts := make([]*types.Account, len(resp.Wallets))
|
||||
for i, w := range resp.Wallets {
|
||||
accts[i] = &types.Account{
|
||||
Provider: "bitgo",
|
||||
ProviderID: w.ID,
|
||||
AccountNumber: w.Label,
|
||||
Currency: w.Coin,
|
||||
Status: "active",
|
||||
AccountType: "wallet",
|
||||
}
|
||||
}
|
||||
return accts, nil
|
||||
}
|
||||
|
||||
// --- Portfolio ---
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, providerAccountID string) (*types.Portfolio, error) {
|
||||
// Get wallet balance
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/wallet/"+providerAccountID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var wallet struct {
|
||||
Coin string `json:"coin"`
|
||||
Balance int64 `json:"balance"`
|
||||
// Balance is in base units (satoshi for BTC, wei for ETH, etc.)
|
||||
}
|
||||
json.Unmarshal(data, &wallet)
|
||||
|
||||
return &types.Portfolio{
|
||||
AccountID: providerAccountID,
|
||||
Cash: fmt.Sprintf("%d", wallet.Balance),
|
||||
PortfolioValue: fmt.Sprintf("%d", wallet.Balance),
|
||||
Positions: []types.Position{
|
||||
{
|
||||
Symbol: wallet.Coin,
|
||||
Qty: fmt.Sprintf("%d", wallet.Balance),
|
||||
AssetClass: "crypto",
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Orders (BitGo Prime Trading) ---
|
||||
|
||||
func (p *Provider) CreateOrder(ctx context.Context, _ string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
body := map[string]interface{}{
|
||||
"product": req.Symbol,
|
||||
"side": req.Side,
|
||||
"type": req.Type,
|
||||
"quantity": req.Qty,
|
||||
}
|
||||
if req.LimitPrice != "" {
|
||||
body["limitPrice"] = req.LimitPrice
|
||||
}
|
||||
|
||||
data, _, err := p.do(ctx, http.MethodPost, "/prime/trading/orders", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
return &types.Order{
|
||||
Provider: "bitgo",
|
||||
ProviderID: raw.ID,
|
||||
Symbol: req.Symbol,
|
||||
Qty: req.Qty,
|
||||
Side: req.Side,
|
||||
Type: req.Type,
|
||||
Status: raw.Status,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, _ string) ([]*types.Order, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/prime/trading/orders", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Orders []struct {
|
||||
ID string `json:"id"`
|
||||
Product string `json:"product"`
|
||||
Side string `json:"side"`
|
||||
Status string `json:"status"`
|
||||
Qty string `json:"quantity"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
orders := make([]*types.Order, len(resp.Orders))
|
||||
for i, o := range resp.Orders {
|
||||
orders[i] = &types.Order{
|
||||
Provider: "bitgo",
|
||||
ProviderID: o.ID,
|
||||
Symbol: o.Product,
|
||||
Side: o.Side,
|
||||
Status: o.Status,
|
||||
Qty: o.Qty,
|
||||
}
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, _ string, orderID string) (*types.Order, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/prime/trading/orders/"+orderID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
ID string `json:"id"`
|
||||
Product string `json:"product"`
|
||||
Side string `json:"side"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
return &types.Order{
|
||||
Provider: "bitgo",
|
||||
ProviderID: raw.ID,
|
||||
Symbol: raw.Product,
|
||||
Side: raw.Side,
|
||||
Status: raw.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(ctx context.Context, _ string, orderID string) error {
|
||||
_, _, err := p.do(ctx, http.MethodDelete, "/prime/trading/orders/"+orderID, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Transfers (crypto sends) ---
|
||||
|
||||
func (p *Provider) CreateTransfer(ctx context.Context, providerAccountID string, req *types.CreateTransferRequest) (*types.Transfer, error) {
|
||||
// BitGo: providerAccountID is wallet ID, req has amount/direction
|
||||
// This is a crypto send, not ACH
|
||||
return nil, fmt.Errorf("bitgo: use wallet send APIs directly for crypto transfers")
|
||||
}
|
||||
|
||||
func (p *Provider) ListTransfers(ctx context.Context, providerAccountID string) ([]*types.Transfer, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/wallet/"+providerAccountID+"/transfers", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Transfers []struct {
|
||||
ID string `json:"id"`
|
||||
Coin string `json:"coin"`
|
||||
Value int64 `json:"value"`
|
||||
State string `json:"state"`
|
||||
TxID string `json:"txid"`
|
||||
Date string `json:"date"`
|
||||
} `json:"transfers"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
transfers := make([]*types.Transfer, len(resp.Transfers))
|
||||
for i, t := range resp.Transfers {
|
||||
dir := "incoming"
|
||||
if t.Value < 0 {
|
||||
dir = "outgoing"
|
||||
}
|
||||
transfers[i] = &types.Transfer{
|
||||
Provider: "bitgo",
|
||||
ProviderID: t.ID,
|
||||
AccountID: providerAccountID,
|
||||
Type: "crypto",
|
||||
Direction: dir,
|
||||
Amount: fmt.Sprintf("%d", t.Value),
|
||||
Currency: t.Coin,
|
||||
Status: t.State,
|
||||
}
|
||||
if ts, err := time.Parse(time.RFC3339, t.Date); err == nil {
|
||||
transfers[i].CreatedAt = ts
|
||||
}
|
||||
}
|
||||
return transfers, nil
|
||||
}
|
||||
|
||||
// --- Bank Relationships (not applicable) ---
|
||||
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) {
|
||||
return nil, fmt.Errorf("bitgo: bank relationships not applicable for crypto custody")
|
||||
}
|
||||
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) {
|
||||
return []*types.BankRelationship{}, nil
|
||||
}
|
||||
|
||||
// --- Assets ---
|
||||
|
||||
func (p *Provider) ListAssets(ctx context.Context, _ string) ([]*types.Asset, error) {
|
||||
// BitGo supported coins
|
||||
coins := []struct {
|
||||
symbol string
|
||||
name string
|
||||
}{
|
||||
{"btc", "Bitcoin"}, {"eth", "Ethereum"}, {"ltc", "Litecoin"},
|
||||
{"xrp", "XRP"}, {"xlm", "Stellar"}, {"eos", "EOS"},
|
||||
{"trx", "Tron"}, {"sol", "Solana"}, {"avax", "Avalanche"},
|
||||
{"dot", "Polkadot"}, {"algo", "Algorand"}, {"near", "NEAR"},
|
||||
{"usdt", "Tether"}, {"usdc", "USD Coin"}, {"dai", "Dai"},
|
||||
}
|
||||
assets := make([]*types.Asset, len(coins))
|
||||
for i, c := range coins {
|
||||
assets[i] = &types.Asset{
|
||||
ID: c.symbol,
|
||||
Provider: "bitgo",
|
||||
Symbol: c.symbol,
|
||||
Name: c.name,
|
||||
Class: "crypto",
|
||||
Status: "active",
|
||||
Tradable: true,
|
||||
}
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(_ context.Context, symbolOrID string) (*types.Asset, error) {
|
||||
return &types.Asset{
|
||||
ID: symbolOrID,
|
||||
Provider: "bitgo",
|
||||
Symbol: symbolOrID,
|
||||
Class: "crypto",
|
||||
Status: "active",
|
||||
Tradable: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Market Data ---
|
||||
|
||||
func (p *Provider) GetSnapshot(_ context.Context, _ string) (*types.MarketSnapshot, error) {
|
||||
return nil, fmt.Errorf("bitgo: market data not supported")
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshots(_ context.Context, _ []string) (map[string]*types.MarketSnapshot, error) {
|
||||
return nil, fmt.Errorf("bitgo: market data not supported")
|
||||
}
|
||||
|
||||
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) {
|
||||
return nil, fmt.Errorf("bitgo: market data not supported")
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestTrades(_ context.Context, _ []string) (map[string]*types.Trade, error) {
|
||||
return nil, fmt.Errorf("bitgo: market data not supported")
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestQuotes(_ context.Context, _ []string) (map[string]*types.Quote, error) {
|
||||
return nil, fmt.Errorf("bitgo: market data not supported")
|
||||
}
|
||||
|
||||
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) {
|
||||
return nil, fmt.Errorf("bitgo: clock not supported")
|
||||
}
|
||||
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) {
|
||||
return nil, fmt.Errorf("bitgo: calendar not supported")
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package circle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ProdURL = "https://api.circle.com"
|
||||
SandboxURL = "https://api-sandbox.circle.com"
|
||||
)
|
||||
|
||||
type Config struct{ BaseURL, APIKey string }
|
||||
|
||||
type Provider struct{ cfg Config; client *http.Client }
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" { cfg.BaseURL = ProdURL }
|
||||
return &Provider{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "circle" }
|
||||
|
||||
var errNotSupported = fmt.Errorf("not supported by circle")
|
||||
|
||||
func (p *Provider) doRequest(ctx context.Context, method, path string, body interface{}) ([]byte, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil { b, _ := json.Marshal(body); reqBody = bytes.NewReader(b) }
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, reqBody)
|
||||
if err != nil { return nil, err }
|
||||
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil { return nil, err }
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 { return nil, fmt.Errorf("circle error %d: %s", resp.StatusCode, string(data)) }
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CreateAccount(ctx context.Context, req *types.CreateAccountRequest) (*types.Account, error) {
|
||||
body := map[string]interface{}{"idempotencyKey": fmt.Sprintf("lux_%d", time.Now().UnixNano()), "description": req.Identity.GivenName}
|
||||
data, err := p.doRequest(ctx, "POST", "/v1/businessAccount/wallets", body)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Data struct{ WalletID string `json:"walletId"` } `json:"data"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Account{ID: resp.Data.WalletID, Provider: "circle", ProviderID: resp.Data.WalletID, Status: "active", Currency: "USD", CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, id string) (*types.Account, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/businessAccount/wallets/"+id, nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Data struct{ WalletID string `json:"walletId"`; EntityID string `json:"entityId"` } `json:"data"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Account{ID: resp.Data.WalletID, Provider: "circle", ProviderID: resp.Data.WalletID, Status: "active"}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/businessAccount/wallets", nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Data []struct{ WalletID string `json:"walletId"` } `json:"data"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
accts := make([]*types.Account, 0, len(resp.Data))
|
||||
for _, w := range resp.Data { accts = append(accts, &types.Account{ID: w.WalletID, Provider: "circle", ProviderID: w.WalletID, Status: "active"}) }
|
||||
return accts, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, id string) (*types.Portfolio, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/businessAccount/balances", nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Data struct{ Available []struct{ Amount string `json:"amount"`; Currency string `json:"currency"` } `json:"available"` } `json:"data"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
positions := make([]types.Position, 0)
|
||||
for _, b := range resp.Data.Available { positions = append(positions, types.Position{Symbol: b.Currency, Qty: b.Amount, AssetClass: "crypto"}) }
|
||||
return &types.Portfolio{AccountID: id, Positions: positions}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CreateOrder(ctx context.Context, accountID string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
body := map[string]interface{}{"idempotencyKey": fmt.Sprintf("lux_%d", time.Now().UnixNano()), "source": map[string]string{"type": "wallet", "id": accountID}, "amount": map[string]string{"amount": req.Qty, "currency": "USD"}, "destination": map[string]string{"type": "blockchain", "chain": "ETH"}}
|
||||
data, err := p.doRequest(ctx, "POST", "/v1/businessAccount/transfers", body)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Data struct{ ID string `json:"id"`; Status string `json:"status"` } `json:"data"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Order{ID: resp.Data.ID, Provider: "circle", ProviderID: resp.Data.ID, AccountID: accountID, Symbol: req.Symbol, Qty: req.Qty, Side: req.Side, Status: resp.Data.Status, CreatedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, accountID string) ([]*types.Order, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/businessAccount/transfers", nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Data []struct{ ID string `json:"id"`; Status string `json:"status"` } `json:"data"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
orders := make([]*types.Order, 0, len(resp.Data))
|
||||
for _, t := range resp.Data { orders = append(orders, &types.Order{ID: t.ID, Provider: "circle", ProviderID: t.ID, Status: t.Status}) }
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, accountID, orderID string) (*types.Order, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/businessAccount/transfers/"+orderID, nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Data struct{ ID string `json:"id"`; Status string `json:"status"` } `json:"data"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Order{ID: resp.Data.ID, Provider: "circle", ProviderID: resp.Data.ID, Status: resp.Data.Status}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(_ context.Context, _, _ string) error { return errNotSupported }
|
||||
func (p *Provider) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) { return nil, errNotSupported }
|
||||
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) { return nil, errNotSupported }
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) { return nil, errNotSupported }
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) { return nil, errNotSupported }
|
||||
func (p *Provider) ListAssets(_ context.Context, _ string) ([]*types.Asset, error) {
|
||||
return []*types.Asset{
|
||||
{ID: "USDC", Provider: "circle", Symbol: "USDC", Name: "USD Coin", Class: "crypto", Status: "active", Tradable: true},
|
||||
{ID: "EURC", Provider: "circle", Symbol: "EURC", Name: "Euro Coin", Class: "crypto", Status: "active", Tradable: true},
|
||||
}, nil
|
||||
}
|
||||
func (p *Provider) GetAsset(_ context.Context, sym string) (*types.Asset, error) {
|
||||
return &types.Asset{ID: sym, Provider: "circle", Symbol: sym, Class: "crypto", Status: "active", Tradable: true}, nil
|
||||
}
|
||||
func (p *Provider) GetSnapshot(_ context.Context, _ string) (*types.MarketSnapshot, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetSnapshots(_ context.Context, _ []string) (map[string]*types.MarketSnapshot, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetLatestTrades(_ context.Context, _ []string) (map[string]*types.Trade, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetLatestQuotes(_ context.Context, _ []string) (map[string]*types.Quote, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) { return nil, errNotSupported }
|
||||
@@ -0,0 +1,352 @@
|
||||
package coinbase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ProdURL = "https://api.coinbase.com/api/v3/brokerage"
|
||||
SandboxURL = "https://api-public.sandbox.exchange.coinbase.com"
|
||||
)
|
||||
|
||||
// Config for Coinbase Advanced Trade API.
|
||||
type Config struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
APISecret string `json:"api_secret"`
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = ProdURL
|
||||
}
|
||||
return &Provider{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "coinbase" }
|
||||
|
||||
func (p *Provider) do(ctx context.Context, method, path string, body interface{}) ([]byte, int, error) {
|
||||
var reqBody io.Reader
|
||||
var bodyBytes []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
bodyBytes, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
reqBody = bytes.NewReader(bodyBytes)
|
||||
}
|
||||
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
msg := ts + method + path + string(bodyBytes)
|
||||
mac := hmac.New(sha256.New, []byte(p.cfg.APISecret))
|
||||
mac.Write([]byte(msg))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("CB-ACCESS-KEY", p.cfg.APIKey)
|
||||
req.Header.Set("CB-ACCESS-SIGN", sig)
|
||||
req.Header.Set("CB-ACCESS-TIMESTAMP", ts)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, resp.StatusCode, fmt.Errorf("coinbase %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// --- Accounts ---
|
||||
|
||||
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) {
|
||||
return nil, fmt.Errorf("coinbase: account creation via coinbase.com")
|
||||
}
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, id string) (*types.Account, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/accounts/"+id, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Account struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Currency string `json:"currency"`
|
||||
Active bool `json:"active"`
|
||||
} `json:"account"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
status := "inactive"
|
||||
if raw.Account.Active {
|
||||
status = "active"
|
||||
}
|
||||
return &types.Account{
|
||||
Provider: "coinbase", ProviderID: raw.Account.UUID,
|
||||
AccountNumber: raw.Account.Name, Currency: raw.Account.Currency,
|
||||
Status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/accounts?limit=50", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Accounts []struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Currency string `json:"currency"`
|
||||
Active bool `json:"active"`
|
||||
Balance struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"available_balance"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
accts := make([]*types.Account, len(raw.Accounts))
|
||||
for i, a := range raw.Accounts {
|
||||
status := "inactive"
|
||||
if a.Active {
|
||||
status = "active"
|
||||
}
|
||||
accts[i] = &types.Account{
|
||||
Provider: "coinbase", ProviderID: a.UUID,
|
||||
AccountNumber: a.Name, Currency: a.Currency, Status: status,
|
||||
}
|
||||
}
|
||||
return accts, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, _ string) (*types.Portfolio, error) {
|
||||
accts, err := p.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &types.Portfolio{Positions: make([]types.Position, 0, len(accts))}, nil
|
||||
}
|
||||
|
||||
// --- Orders ---
|
||||
|
||||
func (p *Provider) CreateOrder(ctx context.Context, _ string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
productID := normalizeProductID(req.Symbol)
|
||||
body := map[string]interface{}{
|
||||
"client_order_id": fmt.Sprintf("lux-%d", time.Now().UnixNano()),
|
||||
"product_id": productID,
|
||||
"side": strings.ToUpper(req.Side),
|
||||
}
|
||||
switch req.Type {
|
||||
case "market":
|
||||
if req.Side == "buy" {
|
||||
body["order_configuration"] = map[string]interface{}{
|
||||
"market_market_ioc": map[string]string{"quote_size": req.Qty},
|
||||
}
|
||||
} else {
|
||||
body["order_configuration"] = map[string]interface{}{
|
||||
"market_market_ioc": map[string]string{"base_size": req.Qty},
|
||||
}
|
||||
}
|
||||
case "limit":
|
||||
body["order_configuration"] = map[string]interface{}{
|
||||
"limit_limit_gtc": map[string]string{
|
||||
"base_size": req.Qty,
|
||||
"limit_price": req.LimitPrice,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
data, _, err := p.do(ctx, http.MethodPost, "/orders", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
status := "new"
|
||||
if !raw.Success {
|
||||
status = "rejected"
|
||||
}
|
||||
return &types.Order{
|
||||
Provider: "coinbase", ProviderID: raw.OrderID,
|
||||
Symbol: req.Symbol, Qty: req.Qty, Side: req.Side,
|
||||
Type: req.Type, Status: status, CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, _ string) ([]*types.Order, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/orders/historical/batch?limit=50", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Orders []struct {
|
||||
OrderID string `json:"order_id"`
|
||||
ProductID string `json:"product_id"`
|
||||
Side string `json:"side"`
|
||||
Status string `json:"status"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
orders := make([]*types.Order, len(raw.Orders))
|
||||
for i, o := range raw.Orders {
|
||||
orders[i] = &types.Order{
|
||||
Provider: "coinbase", ProviderID: o.OrderID,
|
||||
Symbol: o.ProductID, Side: strings.ToLower(o.Side), Status: strings.ToLower(o.Status),
|
||||
}
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, _ string, orderID string) (*types.Order, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/orders/historical/"+orderID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Order struct {
|
||||
OrderID string `json:"order_id"`
|
||||
ProductID string `json:"product_id"`
|
||||
Side string `json:"side"`
|
||||
Status string `json:"status"`
|
||||
FilledSize string `json:"filled_size"`
|
||||
AveragePrice string `json:"average_filled_price"`
|
||||
} `json:"order"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
return &types.Order{
|
||||
Provider: "coinbase", ProviderID: raw.Order.OrderID,
|
||||
Symbol: raw.Order.ProductID, Side: strings.ToLower(raw.Order.Side),
|
||||
Status: strings.ToLower(raw.Order.Status),
|
||||
FilledQty: raw.Order.FilledSize, FilledAvgPrice: raw.Order.AveragePrice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(ctx context.Context, _ string, orderID string) error {
|
||||
_, _, err := p.do(ctx, http.MethodPost, "/orders/batch_cancel", map[string]interface{}{
|
||||
"order_ids": []string{orderID},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Stubs ---
|
||||
|
||||
func (p *Provider) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) {
|
||||
return nil, fmt.Errorf("coinbase: transfers via coinbase.com")
|
||||
}
|
||||
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) {
|
||||
return []*types.Transfer{}, nil
|
||||
}
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) {
|
||||
return nil, fmt.Errorf("coinbase: bank linking via coinbase.com")
|
||||
}
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) {
|
||||
return []*types.BankRelationship{}, nil
|
||||
}
|
||||
|
||||
// --- Assets ---
|
||||
|
||||
func (p *Provider) ListAssets(ctx context.Context, _ string) ([]*types.Asset, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/products?limit=250", nil)
|
||||
if err != nil {
|
||||
return defaultAssets(), nil
|
||||
}
|
||||
var raw struct {
|
||||
Products []struct {
|
||||
ProductID string `json:"product_id"`
|
||||
BaseCurrency string `json:"base_currency_id"`
|
||||
QuoteCurrency string `json:"quote_currency_id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"products"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
assets := make([]*types.Asset, 0, len(raw.Products))
|
||||
for _, prod := range raw.Products {
|
||||
if prod.QuoteCurrency != "USD" && prod.QuoteCurrency != "USDC" {
|
||||
continue
|
||||
}
|
||||
assets = append(assets, &types.Asset{
|
||||
ID: prod.ProductID, Provider: "coinbase",
|
||||
Symbol: prod.BaseCurrency + "/" + prod.QuoteCurrency,
|
||||
Name: prod.ProductID, Class: "crypto",
|
||||
Status: "active", Tradable: true, Fractionable: true,
|
||||
})
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(_ context.Context, sym string) (*types.Asset, error) {
|
||||
return &types.Asset{
|
||||
ID: sym, Provider: "coinbase", Symbol: sym,
|
||||
Class: "crypto", Status: "active", Tradable: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshot(_ context.Context, _ string) (*types.MarketSnapshot, error) {
|
||||
return nil, fmt.Errorf("coinbase: use websocket for real-time data")
|
||||
}
|
||||
func (p *Provider) GetSnapshots(_ context.Context, _ []string) (map[string]*types.MarketSnapshot, error) {
|
||||
return nil, fmt.Errorf("coinbase: use websocket for real-time data")
|
||||
}
|
||||
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) {
|
||||
return nil, fmt.Errorf("coinbase: bars not yet implemented")
|
||||
}
|
||||
func (p *Provider) GetLatestTrades(_ context.Context, _ []string) (map[string]*types.Trade, error) {
|
||||
return nil, fmt.Errorf("coinbase: not yet implemented")
|
||||
}
|
||||
func (p *Provider) GetLatestQuotes(_ context.Context, _ []string) (map[string]*types.Quote, error) {
|
||||
return nil, fmt.Errorf("coinbase: not yet implemented")
|
||||
}
|
||||
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) {
|
||||
return &types.MarketClock{Timestamp: time.Now().UTC().Format(time.RFC3339), IsOpen: true}, nil
|
||||
}
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) {
|
||||
return nil, fmt.Errorf("coinbase: crypto is 24/7")
|
||||
}
|
||||
|
||||
func normalizeProductID(sym string) string {
|
||||
s := strings.ToUpper(sym)
|
||||
s = strings.ReplaceAll(s, "/", "-")
|
||||
if !strings.Contains(s, "-") {
|
||||
if len(s) > 3 {
|
||||
return s[:len(s)-3] + "-" + s[len(s)-3:]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func defaultAssets() []*types.Asset {
|
||||
syms := []string{"BTC", "ETH", "SOL", "AVAX", "DOT", "LINK", "UNI", "AAVE", "MATIC", "ARB", "OP", "LTC", "XRP", "ADA", "DOGE", "SHIB", "ATOM", "NEAR"}
|
||||
out := make([]*types.Asset, len(syms))
|
||||
for i, s := range syms {
|
||||
out[i] = &types.Asset{ID: s + "-USD", Provider: "coinbase", Symbol: s + "/USD", Class: "crypto", Status: "active", Tradable: true, Fractionable: true}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package currencycloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
// CurrencyCloud (Visa) — Institutional FX trading + cross-border payments.
|
||||
// 35+ currencies, competitive spot FX, forward contracts.
|
||||
|
||||
const (
|
||||
ProdURL = "https://api.currencycloud.com"
|
||||
DemoURL = "https://devapi.currencycloud.com"
|
||||
)
|
||||
|
||||
type Config struct{ BaseURL, LoginID, APIKey string }
|
||||
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
authToken string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" { cfg.BaseURL = ProdURL }
|
||||
return &Provider{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "currencycloud" }
|
||||
|
||||
var errNotSupported = fmt.Errorf("not supported by currencycloud")
|
||||
|
||||
func (p *Provider) authenticate(ctx context.Context) error {
|
||||
if p.authToken != "" && time.Now().Before(p.tokenExp) { return nil }
|
||||
form := url.Values{"login_id": {p.cfg.LoginID}, "api_key": {p.cfg.APIKey}}
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", p.cfg.BaseURL+"/v2/authenticate/api", strings.NewReader(form.Encode()))
|
||||
if err != nil { return err }
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil { return err }
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 { return fmt.Errorf("currencycloud auth error %d: %s", resp.StatusCode, string(data)) }
|
||||
var result struct{ AuthToken string `json:"auth_token"` }
|
||||
json.Unmarshal(data, &result)
|
||||
p.authToken = result.AuthToken
|
||||
p.tokenExp = time.Now().Add(25 * time.Minute) // tokens last 30min
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) doRequest(ctx context.Context, method, path string, body interface{}) ([]byte, error) {
|
||||
if err := p.authenticate(ctx); err != nil { return nil, err }
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
if form, ok := body.(url.Values); ok {
|
||||
reqBody = strings.NewReader(form.Encode())
|
||||
} else {
|
||||
b, _ := json.Marshal(body)
|
||||
reqBody = bytes.NewReader(b)
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, reqBody)
|
||||
if err != nil { return nil, err }
|
||||
req.Header.Set("X-Auth-Token", p.authToken)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil { return nil, err }
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 { return nil, fmt.Errorf("currencycloud error %d: %s", resp.StatusCode, string(data)) }
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// CurrencyCloud uses sub-accounts, not brokerage accounts
|
||||
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) { return nil, errNotSupported }
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, id string) (*types.Account, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v2/accounts/"+id, nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ ID string `json:"id"`; AccountName string `json:"account_name"`; Status string `json:"status"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Account{ID: resp.ID, Provider: "currencycloud", ProviderID: resp.ID, AccountNumber: resp.AccountName, Status: resp.Status}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v2/accounts/find", nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Accounts []struct{ ID string `json:"id"`; AccountName string `json:"account_name"`; Status string `json:"status"` } `json:"accounts"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
accts := make([]*types.Account, 0, len(resp.Accounts))
|
||||
for _, a := range resp.Accounts {
|
||||
accts = append(accts, &types.Account{ID: a.ID, Provider: "currencycloud", ProviderID: a.ID, AccountNumber: a.AccountName, Status: a.Status})
|
||||
}
|
||||
return accts, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, id string) (*types.Portfolio, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v2/balances/find", nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Balances []struct{ Currency string `json:"currency"`; Amount string `json:"amount"` } `json:"balances"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
positions := make([]types.Position, 0, len(resp.Balances))
|
||||
for _, b := range resp.Balances {
|
||||
positions = append(positions, types.Position{Symbol: b.Currency, Qty: b.Amount, AssetClass: "forex"})
|
||||
}
|
||||
return &types.Portfolio{AccountID: id, Positions: positions}, nil
|
||||
}
|
||||
|
||||
// FX "orders" are conversions in CurrencyCloud
|
||||
func (p *Provider) CreateOrder(ctx context.Context, accountID string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
// Parse symbol as currency pair (e.g. GBPUSD -> buy GBP, sell USD)
|
||||
if len(req.Symbol) != 6 { return nil, fmt.Errorf("symbol must be 6-char FX pair (e.g. GBPUSD)") }
|
||||
buyCcy := req.Symbol[:3]
|
||||
sellCcy := req.Symbol[3:]
|
||||
form := url.Values{
|
||||
"buy_currency": {buyCcy},
|
||||
"sell_currency": {sellCcy},
|
||||
"amount": {req.Qty},
|
||||
"fixed_side": {"buy"},
|
||||
"term_agreement": {"true"},
|
||||
}
|
||||
data, err := p.doRequest(ctx, "POST", "/v2/conversions/create", form)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ ID string `json:"id"`; Status string `json:"status"`; ClientRate string `json:"client_rate"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Order{ID: resp.ID, Provider: "currencycloud", ProviderID: resp.ID, AccountID: accountID, Symbol: req.Symbol, Qty: req.Qty, Side: req.Side, Type: "fx_spot", Status: resp.Status, FilledAvgPrice: resp.ClientRate, CreatedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, accountID string) ([]*types.Order, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v2/conversions/find", nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Conversions []struct{ ID string `json:"id"`; BuyCurrency string `json:"buy_currency"`; SellCurrency string `json:"sell_currency"`; Status string `json:"status"` } `json:"conversions"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
orders := make([]*types.Order, 0, len(resp.Conversions))
|
||||
for _, c := range resp.Conversions {
|
||||
orders = append(orders, &types.Order{ID: c.ID, Provider: "currencycloud", ProviderID: c.ID, Symbol: c.BuyCurrency + c.SellCurrency, Status: c.Status})
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, accountID, orderID string) (*types.Order, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v2/conversions/"+orderID, nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ ID string `json:"id"`; Status string `json:"status"`; ClientRate string `json:"client_rate"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Order{ID: resp.ID, Provider: "currencycloud", ProviderID: resp.ID, Status: resp.Status, FilledAvgPrice: resp.ClientRate}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(ctx context.Context, accountID, orderID string) error {
|
||||
_, err := p.doRequest(ctx, "POST", "/v2/conversions/"+orderID+"/cancel", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *Provider) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) { return nil, errNotSupported }
|
||||
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) { return nil, errNotSupported }
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) { return nil, errNotSupported }
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) { return nil, errNotSupported }
|
||||
|
||||
// FX pairs as assets
|
||||
func (p *Provider) ListAssets(_ context.Context, _ string) ([]*types.Asset, error) {
|
||||
pairs := []string{"EURUSD", "GBPUSD", "USDJPY", "USDCHF", "AUDUSD", "USDCAD", "NZDUSD", "EURGBP", "EURJPY", "GBPJPY",
|
||||
"EURCAD", "EURAUD", "EURNZD", "GBPAUD", "GBPCAD", "AUDCAD", "AUDNZD", "AUDJPY", "CADJPY", "CHFJPY",
|
||||
"USDSGD", "USDHKD", "USDMXN", "USDZAR", "USDTRY", "USDPLN", "USDSEK", "USDNOK", "USDDKK", "USDCZK",
|
||||
"USDHUF", "USDILS", "USDTHB", "USDINR", "USDCNH"}
|
||||
assets := make([]*types.Asset, 0, len(pairs))
|
||||
for _, pair := range pairs {
|
||||
assets = append(assets, &types.Asset{ID: pair, Provider: "currencycloud", Symbol: pair, Name: pair[:3] + "/" + pair[3:], Class: "forex", Status: "active", Tradable: true})
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(_ context.Context, sym string) (*types.Asset, error) {
|
||||
return &types.Asset{ID: sym, Provider: "currencycloud", Symbol: sym, Name: sym[:3] + "/" + sym[3:], Class: "forex", Status: "active", Tradable: true}, nil
|
||||
}
|
||||
|
||||
// FX rates as market snapshots
|
||||
func (p *Provider) GetSnapshot(ctx context.Context, symbol string) (*types.MarketSnapshot, error) {
|
||||
if len(symbol) != 6 { return nil, fmt.Errorf("symbol must be 6-char FX pair") }
|
||||
pair := symbol[:3] + symbol[3:]
|
||||
form := url.Values{"currency_pair": {pair}}
|
||||
data, err := p.doRequest(ctx, "GET", "/v2/rates/detailed?currency_pair="+pair, form)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ BidPrice float64 `json:"client_buy_price,string"`; OfferPrice float64 `json:"client_sell_price,string"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
mid := (resp.BidPrice + resp.OfferPrice) / 2
|
||||
return &types.MarketSnapshot{Symbol: symbol, LatestQuote: &types.Quote{BidPrice: resp.BidPrice, AskPrice: resp.OfferPrice}, LatestTrade: &types.Trade{Price: mid}}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshots(ctx context.Context, symbols []string) (map[string]*types.MarketSnapshot, error) {
|
||||
result := make(map[string]*types.MarketSnapshot)
|
||||
for _, sym := range symbols {
|
||||
snap, err := p.GetSnapshot(ctx, sym)
|
||||
if err == nil { result[sym] = snap }
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) { return nil, errNotSupported }
|
||||
|
||||
func (p *Provider) GetLatestTrades(ctx context.Context, symbols []string) (map[string]*types.Trade, error) {
|
||||
snaps, err := p.GetSnapshots(ctx, symbols)
|
||||
if err != nil { return nil, err }
|
||||
result := make(map[string]*types.Trade)
|
||||
for sym, snap := range snaps { if snap.LatestTrade != nil { result[sym] = snap.LatestTrade } }
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestQuotes(ctx context.Context, symbols []string) (map[string]*types.Quote, error) {
|
||||
snaps, err := p.GetSnapshots(ctx, symbols)
|
||||
if err != nil { return nil, err }
|
||||
result := make(map[string]*types.Quote)
|
||||
for sym, snap := range snaps { if snap.LatestQuote != nil { result[sym] = snap.LatestQuote } }
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) {
|
||||
// FX market is open 24/5
|
||||
now := time.Now().UTC()
|
||||
wd := now.Weekday()
|
||||
isOpen := wd >= time.Monday && wd <= time.Friday
|
||||
return &types.MarketClock{IsOpen: isOpen, Timestamp: now.Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) { return nil, errNotSupported }
|
||||
@@ -0,0 +1,300 @@
|
||||
package falcon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
const (
|
||||
SandboxURL = "https://api.sandbox.falconx.io"
|
||||
ProdURL = "https://api.falconx.io"
|
||||
)
|
||||
|
||||
// Config for FalconX provider.
|
||||
// FalconX is an institutional crypto trading platform with RFQ (Request for Quote) model.
|
||||
type Config struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
APISecret string `json:"api_secret"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
}
|
||||
|
||||
// Provider implements the broker Provider interface for FalconX.
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = SandboxURL
|
||||
}
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "falcon" }
|
||||
|
||||
func (p *Provider) do(ctx context.Context, method, path string, body interface{}) ([]byte, int, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
reqBody = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("FX-ACCESS-KEY", p.cfg.APIKey)
|
||||
req.Header.Set("FX-ACCESS-PASSPHRASE", p.cfg.Passphrase)
|
||||
// FalconX uses HMAC-SHA256 signature — simplified here
|
||||
req.Header.Set("FX-ACCESS-SIGN", "")
|
||||
req.Header.Set("FX-ACCESS-TIMESTAMP", fmt.Sprintf("%d", time.Now().Unix()))
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, resp.StatusCode, fmt.Errorf("falcon %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// --- Accounts ---
|
||||
|
||||
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) {
|
||||
return nil, fmt.Errorf("falcon: account creation handled via FalconX onboarding")
|
||||
}
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, providerAccountID string) (*types.Account, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/v1/balances", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = data
|
||||
return &types.Account{
|
||||
Provider: "falcon",
|
||||
ProviderID: providerAccountID,
|
||||
Status: "active",
|
||||
Currency: "USD",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
return []*types.Account{{
|
||||
Provider: "falcon",
|
||||
ProviderID: "default",
|
||||
Status: "active",
|
||||
Currency: "USD",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// --- Portfolio ---
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, _ string) (*types.Portfolio, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/v1/balances", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp []struct {
|
||||
Platform string `json:"platform"`
|
||||
Token string `json:"token"`
|
||||
Balance float64 `json:"balance"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
positions := make([]types.Position, len(resp))
|
||||
for i, b := range resp {
|
||||
positions[i] = types.Position{
|
||||
Symbol: b.Token,
|
||||
Qty: fmt.Sprintf("%.8f", b.Balance),
|
||||
AssetClass: "crypto",
|
||||
}
|
||||
}
|
||||
return &types.Portfolio{
|
||||
AccountID: "default",
|
||||
Positions: positions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Orders (RFQ model) ---
|
||||
|
||||
func (p *Provider) CreateOrder(ctx context.Context, _ string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
// FalconX uses RFQ: get quote first, then execute
|
||||
quoteBody := map[string]interface{}{
|
||||
"token_pair": map[string]string{
|
||||
"base_token": req.Symbol,
|
||||
"quote_token": "USD",
|
||||
},
|
||||
"quantity": map[string]string{
|
||||
"token": req.Symbol,
|
||||
"value": req.Qty,
|
||||
},
|
||||
"side": req.Side,
|
||||
}
|
||||
|
||||
data, _, err := p.do(ctx, http.MethodPost, "/v1/quotes", quoteBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var quote struct {
|
||||
FxQuoteID string `json:"fx_quote_id"`
|
||||
BuyPrice float64 `json:"buy_price"`
|
||||
SellPrice float64 `json:"sell_price"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
json.Unmarshal(data, "e)
|
||||
|
||||
// Execute the quote
|
||||
execData, _, err := p.do(ctx, http.MethodPost, "/v1/quotes/execute", map[string]string{
|
||||
"fx_quote_id": quote.FxQuoteID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var exec struct {
|
||||
FxQuoteID string `json:"fx_quote_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
json.Unmarshal(execData, &exec)
|
||||
|
||||
return &types.Order{
|
||||
Provider: "falcon",
|
||||
ProviderID: exec.FxQuoteID,
|
||||
Symbol: req.Symbol,
|
||||
Qty: req.Qty,
|
||||
Side: req.Side,
|
||||
Type: "rfq",
|
||||
Status: exec.Status,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, _ string) ([]*types.Order, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/v1/trades", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp []struct {
|
||||
FxQuoteID string `json:"fx_quote_id"`
|
||||
BaseToken string `json:"base_token"`
|
||||
Side string `json:"side"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Status string `json:"status"`
|
||||
Timestamp string `json:"t_execute"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
orders := make([]*types.Order, len(resp))
|
||||
for i, t := range resp {
|
||||
orders[i] = &types.Order{
|
||||
Provider: "falcon",
|
||||
ProviderID: t.FxQuoteID,
|
||||
Symbol: t.BaseToken,
|
||||
Side: t.Side,
|
||||
Qty: fmt.Sprintf("%.8f", t.Quantity),
|
||||
FilledAvgPrice: fmt.Sprintf("%.2f", t.Price),
|
||||
Type: "rfq",
|
||||
Status: t.Status,
|
||||
}
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, _ string, orderID string) (*types.Order, error) {
|
||||
orders, err := p.ListOrders(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, o := range orders {
|
||||
if o.ProviderID == orderID {
|
||||
return o, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("falcon: order %s not found", orderID)
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(_ context.Context, _, _ string) error {
|
||||
return fmt.Errorf("falcon: RFQ orders cannot be cancelled after execution")
|
||||
}
|
||||
|
||||
// --- Transfers / Bank / Assets ---
|
||||
|
||||
func (p *Provider) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) {
|
||||
return nil, fmt.Errorf("falcon: transfers handled via settlement")
|
||||
}
|
||||
|
||||
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) {
|
||||
return []*types.Transfer{}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) {
|
||||
return nil, fmt.Errorf("falcon: not applicable")
|
||||
}
|
||||
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) {
|
||||
return []*types.BankRelationship{}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAssets(_ context.Context, _ string) ([]*types.Asset, error) {
|
||||
pairs := []string{"BTC", "ETH", "SOL", "AVAX", "DOT", "LINK", "UNI", "AAVE", "MATIC", "ARB", "OP"}
|
||||
assets := make([]*types.Asset, len(pairs))
|
||||
for i, s := range pairs {
|
||||
assets[i] = &types.Asset{
|
||||
ID: s, Provider: "falcon", Symbol: s, Name: s + "/USD",
|
||||
Class: "crypto", Status: "active", Tradable: true,
|
||||
}
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(_ context.Context, symbolOrID string) (*types.Asset, error) {
|
||||
return &types.Asset{
|
||||
ID: symbolOrID, Provider: "falcon", Symbol: symbolOrID,
|
||||
Class: "crypto", Status: "active", Tradable: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Market Data ---
|
||||
|
||||
func (p *Provider) GetSnapshot(_ context.Context, _ string) (*types.MarketSnapshot, error) {
|
||||
return nil, fmt.Errorf("falcon: market data not supported")
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshots(_ context.Context, _ []string) (map[string]*types.MarketSnapshot, error) {
|
||||
return nil, fmt.Errorf("falcon: market data not supported")
|
||||
}
|
||||
|
||||
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) {
|
||||
return nil, fmt.Errorf("falcon: market data not supported")
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestTrades(_ context.Context, _ []string) (map[string]*types.Trade, error) {
|
||||
return nil, fmt.Errorf("falcon: market data not supported")
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestQuotes(_ context.Context, _ []string) (map[string]*types.Quote, error) {
|
||||
return nil, fmt.Errorf("falcon: market data not supported")
|
||||
}
|
||||
|
||||
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) {
|
||||
return nil, fmt.Errorf("falcon: clock not supported")
|
||||
}
|
||||
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) {
|
||||
return nil, fmt.Errorf("falcon: calendar not supported")
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package finix
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
const (
|
||||
SandboxURL = "https://finix.sandbox-payments-api.com"
|
||||
ProdURL = "https://finix.live-payments-api.com"
|
||||
)
|
||||
|
||||
// Config for Finix provider.
|
||||
// Finix is a payments infrastructure platform (not a traditional broker).
|
||||
// It handles payment processing, merchant onboarding, and fund transfers.
|
||||
type Config struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
Username string `json:"username"` // API key
|
||||
Password string `json:"password"` // API secret
|
||||
}
|
||||
|
||||
// Provider implements the broker Provider interface for Finix.
|
||||
// Maps Finix's payment concepts to the unified broker interface:
|
||||
// - Accounts → Finix Merchants/Identities
|
||||
// - Transfers → Finix Transfers (debits/credits)
|
||||
// - Bank Relationships → Finix Payment Instruments
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = SandboxURL
|
||||
}
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "finix" }
|
||||
|
||||
func (p *Provider) do(ctx context.Context, method, path string, body interface{}) ([]byte, int, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
reqBody = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.SetBasicAuth(p.cfg.Username, p.cfg.Password)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Finix-Version", "2022-02-01")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, resp.StatusCode, fmt.Errorf("finix %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// --- Accounts (Finix Identities → Merchants) ---
|
||||
|
||||
func (p *Provider) CreateAccount(ctx context.Context, req *types.CreateAccountRequest) (*types.Account, error) {
|
||||
// Create a Finix Identity (KYC entity)
|
||||
identity := map[string]interface{}{
|
||||
"entity": map[string]interface{}{
|
||||
"first_name": req.Identity.GivenName,
|
||||
"last_name": req.Identity.FamilyName,
|
||||
"email": req.Contact.Email,
|
||||
"phone": req.Contact.Phone,
|
||||
"personal_address": map[string]interface{}{
|
||||
"line1": firstOrEmpty(req.Contact.Street),
|
||||
"city": req.Contact.City,
|
||||
"region": req.Contact.State,
|
||||
"postal_code": req.Contact.PostalCode,
|
||||
"country": req.Contact.Country,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, _, err := p.do(ctx, http.MethodPost, "/identities", identity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
return &types.Account{
|
||||
Provider: "finix",
|
||||
ProviderID: resp.ID,
|
||||
Status: "active",
|
||||
Currency: "USD",
|
||||
Identity: &types.Identity{
|
||||
GivenName: req.Identity.GivenName,
|
||||
FamilyName: req.Identity.FamilyName,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, providerAccountID string) (*types.Account, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/identities/"+providerAccountID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
Entity struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Email string `json:"email"`
|
||||
} `json:"entity"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Account{
|
||||
Provider: "finix",
|
||||
ProviderID: resp.ID,
|
||||
Status: "active",
|
||||
Currency: "USD",
|
||||
Identity: &types.Identity{
|
||||
GivenName: resp.Entity.FirstName,
|
||||
FamilyName: resp.Entity.LastName,
|
||||
},
|
||||
Contact: &types.Contact{Email: resp.Entity.Email},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/identities", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Embedded struct {
|
||||
Identities []struct {
|
||||
ID string `json:"id"`
|
||||
Entity struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
} `json:"entity"`
|
||||
} `json:"identities"`
|
||||
} `json:"_embedded"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
accts := make([]*types.Account, len(resp.Embedded.Identities))
|
||||
for i, id := range resp.Embedded.Identities {
|
||||
accts[i] = &types.Account{
|
||||
Provider: "finix",
|
||||
ProviderID: id.ID,
|
||||
AccountNumber: id.Entity.FirstName + " " + id.Entity.LastName,
|
||||
Status: "active",
|
||||
Currency: "USD",
|
||||
}
|
||||
}
|
||||
return accts, nil
|
||||
}
|
||||
|
||||
// --- Portfolio (balance of payment instruments) ---
|
||||
|
||||
func (p *Provider) GetPortfolio(_ context.Context, _ string) (*types.Portfolio, error) {
|
||||
return &types.Portfolio{Positions: []types.Position{}}, nil
|
||||
}
|
||||
|
||||
// --- Orders (not applicable for payments) ---
|
||||
|
||||
func (p *Provider) CreateOrder(_ context.Context, _ string, _ *types.CreateOrderRequest) (*types.Order, error) {
|
||||
return nil, fmt.Errorf("finix: trading orders not supported — use transfers for payments")
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(_ context.Context, _ string) ([]*types.Order, error) {
|
||||
return []*types.Order{}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(_ context.Context, _, _ string) (*types.Order, error) {
|
||||
return nil, fmt.Errorf("finix: orders not supported")
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(_ context.Context, _, _ string) error {
|
||||
return fmt.Errorf("finix: orders not supported")
|
||||
}
|
||||
|
||||
// --- Transfers (Finix debits/credits) ---
|
||||
|
||||
func (p *Provider) CreateTransfer(ctx context.Context, providerAccountID string, req *types.CreateTransferRequest) (*types.Transfer, error) {
|
||||
body := map[string]interface{}{
|
||||
"amount": amountToCents(req.Amount),
|
||||
"currency": "USD",
|
||||
"source": req.RelationshipID, // payment instrument ID
|
||||
}
|
||||
|
||||
data, _, err := p.do(ctx, http.MethodPost, "/transfers", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
Amount int64 `json:"amount"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
return &types.Transfer{
|
||||
Provider: "finix",
|
||||
ProviderID: resp.ID,
|
||||
AccountID: providerAccountID,
|
||||
Type: "payment",
|
||||
Direction: req.Direction,
|
||||
Amount: fmt.Sprintf("%.2f", float64(resp.Amount)/100),
|
||||
Currency: "USD",
|
||||
Status: resp.State,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListTransfers(ctx context.Context, providerAccountID string) ([]*types.Transfer, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/transfers", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Embedded struct {
|
||||
Transfers []struct {
|
||||
ID string `json:"id"`
|
||||
Amount int64 `json:"amount"`
|
||||
State string `json:"state"`
|
||||
} `json:"transfers"`
|
||||
} `json:"_embedded"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
transfers := make([]*types.Transfer, len(resp.Embedded.Transfers))
|
||||
for i, t := range resp.Embedded.Transfers {
|
||||
transfers[i] = &types.Transfer{
|
||||
Provider: "finix",
|
||||
ProviderID: t.ID,
|
||||
Type: "payment",
|
||||
Amount: fmt.Sprintf("%.2f", float64(t.Amount)/100),
|
||||
Currency: "USD",
|
||||
Status: t.State,
|
||||
}
|
||||
}
|
||||
return transfers, nil
|
||||
}
|
||||
|
||||
// --- Bank Relationships (Finix Payment Instruments) ---
|
||||
|
||||
func (p *Provider) CreateBankRelationship(ctx context.Context, providerAccountID string, ownerName, accountType, accountNumber, routingNumber string) (*types.BankRelationship, error) {
|
||||
body := map[string]interface{}{
|
||||
"identity": providerAccountID,
|
||||
"type": "BANK_ACCOUNT",
|
||||
"account_type": accountType,
|
||||
"name": ownerName,
|
||||
"account_number": accountNumber,
|
||||
"bank_code": routingNumber,
|
||||
"country": "USA",
|
||||
"currency": "USD",
|
||||
}
|
||||
|
||||
data, _, err := p.do(ctx, http.MethodPost, "/payment_instruments", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"fingerprint"` // using fingerprint as proxy
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
return &types.BankRelationship{
|
||||
Provider: "finix",
|
||||
ProviderID: resp.ID,
|
||||
AccountID: providerAccountID,
|
||||
AccountOwnerName: ownerName,
|
||||
BankAccountType: accountType,
|
||||
Status: "active",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListBankRelationships(ctx context.Context, providerAccountID string) ([]*types.BankRelationship, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/identities/"+providerAccountID+"/payment_instruments", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Embedded struct {
|
||||
Instruments []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
} `json:"payment_instruments"`
|
||||
} `json:"_embedded"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
rels := make([]*types.BankRelationship, 0)
|
||||
for _, pi := range resp.Embedded.Instruments {
|
||||
if pi.Type == "BANK_ACCOUNT" {
|
||||
rels = append(rels, &types.BankRelationship{
|
||||
Provider: "finix",
|
||||
ProviderID: pi.ID,
|
||||
AccountID: providerAccountID,
|
||||
AccountOwnerName: pi.Name,
|
||||
BankAccountType: "BANK_ACCOUNT",
|
||||
Status: "active",
|
||||
})
|
||||
}
|
||||
}
|
||||
return rels, nil
|
||||
}
|
||||
|
||||
// --- Assets (not applicable) ---
|
||||
|
||||
func (p *Provider) ListAssets(_ context.Context, _ string) ([]*types.Asset, error) {
|
||||
return []*types.Asset{}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(_ context.Context, _ string) (*types.Asset, error) {
|
||||
return nil, fmt.Errorf("finix: assets not applicable for payment processing")
|
||||
}
|
||||
|
||||
// --- Market Data ---
|
||||
|
||||
func (p *Provider) GetSnapshot(_ context.Context, _ string) (*types.MarketSnapshot, error) {
|
||||
return nil, fmt.Errorf("finix: market data not applicable")
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshots(_ context.Context, _ []string) (map[string]*types.MarketSnapshot, error) {
|
||||
return nil, fmt.Errorf("finix: market data not applicable")
|
||||
}
|
||||
|
||||
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) {
|
||||
return nil, fmt.Errorf("finix: market data not applicable")
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestTrades(_ context.Context, _ []string) (map[string]*types.Trade, error) {
|
||||
return nil, fmt.Errorf("finix: market data not applicable")
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestQuotes(_ context.Context, _ []string) (map[string]*types.Quote, error) {
|
||||
return nil, fmt.Errorf("finix: market data not applicable")
|
||||
}
|
||||
|
||||
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) {
|
||||
return nil, fmt.Errorf("finix: clock not applicable")
|
||||
}
|
||||
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) {
|
||||
return nil, fmt.Errorf("finix: calendar not applicable")
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func firstOrEmpty(s []string) string {
|
||||
if len(s) > 0 {
|
||||
return s[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func amountToCents(amount string) int64 {
|
||||
// Parse dollar amount to cents
|
||||
var f float64
|
||||
fmt.Sscanf(amount, "%f", &f)
|
||||
return int64(f * 100)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package fireblocks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ProdURL = "https://api.fireblocks.io"
|
||||
SandboxURL = "https://sandbox-api.fireblocks.io"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
PrivateKeyPEM string // RSA private key for JWT signing
|
||||
VaultAccountID string
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = ProdURL
|
||||
}
|
||||
return &Provider{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "fireblocks" }
|
||||
|
||||
var errNotSupported = fmt.Errorf("not supported by fireblocks")
|
||||
|
||||
func (p *Provider) doRequest(ctx context.Context, method, path string, body interface{}) ([]byte, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
reqBody = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+"/v1"+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-API-Key", p.cfg.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// In production, add JWT Authorization header signed with RSA private key
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("fireblocks error %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CreateAccount(ctx context.Context, req *types.CreateAccountRequest) (*types.Account, error) {
|
||||
body := map[string]interface{}{"name": req.Identity.GivenName + " " + req.Identity.FamilyName}
|
||||
data, err := p.doRequest(ctx, "POST", "/vault/accounts", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct{ ID string `json:"id"`; Name string `json:"name"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Account{ID: resp.ID, Provider: "fireblocks", ProviderID: resp.ID, Status: "active", CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, id string) (*types.Account, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/vault/accounts/"+id, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct{ ID string `json:"id"`; Name string `json:"name"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Account{ID: resp.ID, Provider: "fireblocks", ProviderID: resp.ID, Status: "active"}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/vault/accounts", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var items []struct{ ID string `json:"id"`; Name string `json:"name"` }
|
||||
json.Unmarshal(data, &items)
|
||||
accounts := make([]*types.Account, 0, len(items))
|
||||
for _, item := range items {
|
||||
accounts = append(accounts, &types.Account{ID: item.ID, Provider: "fireblocks", ProviderID: item.ID, Status: "active"})
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, id string) (*types.Portfolio, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/vault/accounts/"+id, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Assets []struct{ ID string `json:"id"`; Balance string `json:"balance"`; Available string `json:"available"` } `json:"assets"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
positions := make([]types.Position, 0, len(resp.Assets))
|
||||
for _, a := range resp.Assets {
|
||||
positions = append(positions, types.Position{Symbol: a.ID, Qty: a.Balance, AssetClass: "crypto"})
|
||||
}
|
||||
return &types.Portfolio{AccountID: id, Positions: positions}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CreateOrder(ctx context.Context, accountID string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
body := map[string]interface{}{
|
||||
"assetId": req.Symbol, "amount": req.Qty,
|
||||
"source": map[string]string{"type": "VAULT_ACCOUNT", "id": accountID},
|
||||
"operation": "TRANSFER",
|
||||
}
|
||||
data, err := p.doRequest(ctx, "POST", "/transactions", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct{ ID string `json:"id"`; Status string `json:"status"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Order{ID: resp.ID, Provider: "fireblocks", ProviderID: resp.ID, AccountID: accountID, Symbol: req.Symbol, Qty: req.Qty, Side: req.Side, Type: req.Type, Status: resp.Status, CreatedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, accountID string) ([]*types.Order, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/transactions", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var items []struct{ ID string `json:"id"`; Status string `json:"status"`; AssetID string `json:"assetId"` }
|
||||
json.Unmarshal(data, &items)
|
||||
orders := make([]*types.Order, 0, len(items))
|
||||
for _, item := range items {
|
||||
orders = append(orders, &types.Order{ID: item.ID, Provider: "fireblocks", ProviderID: item.ID, Symbol: item.AssetID, Status: item.Status})
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, accountID, orderID string) (*types.Order, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/transactions/"+orderID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct{ ID string `json:"id"`; Status string `json:"status"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Order{ID: resp.ID, Provider: "fireblocks", ProviderID: resp.ID, Status: resp.Status}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(ctx context.Context, accountID, orderID string) error {
|
||||
_, err := p.doRequest(ctx, "POST", "/transactions/"+orderID+"/cancel", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *Provider) CreateTransfer(ctx context.Context, accountID string, req *types.CreateTransferRequest) (*types.Transfer, error) {
|
||||
return nil, errNotSupported
|
||||
}
|
||||
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) {
|
||||
return nil, errNotSupported
|
||||
}
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) {
|
||||
return nil, errNotSupported
|
||||
}
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) {
|
||||
return nil, errNotSupported
|
||||
}
|
||||
|
||||
func (p *Provider) ListAssets(ctx context.Context, class string) ([]*types.Asset, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/supported_assets", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var items []struct{ ID string `json:"id"`; Name string `json:"name"`; Type string `json:"type"` }
|
||||
json.Unmarshal(data, &items)
|
||||
assets := make([]*types.Asset, 0, len(items))
|
||||
for _, item := range items {
|
||||
assets = append(assets, &types.Asset{ID: item.ID, Provider: "fireblocks", Symbol: item.ID, Name: item.Name, Class: "crypto", Status: "active", Tradable: true})
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(ctx context.Context, symbolOrID string) (*types.Asset, error) {
|
||||
return &types.Asset{ID: symbolOrID, Provider: "fireblocks", Symbol: symbolOrID, Class: "crypto", Status: "active", Tradable: true}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshot(_ context.Context, _ string) (*types.MarketSnapshot, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetSnapshots(_ context.Context, _ []string) (map[string]*types.MarketSnapshot, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetLatestTrades(_ context.Context, _ []string) (map[string]*types.Trade, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetLatestQuotes(_ context.Context, _ []string) (map[string]*types.Quote, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) { return nil, errNotSupported }
|
||||
@@ -0,0 +1,372 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ProdURL = "https://api.gemini.com"
|
||||
SandboxURL = "https://api.sandbox.gemini.com"
|
||||
)
|
||||
|
||||
// Config for Gemini exchange.
|
||||
type Config struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
APISecret string `json:"api_secret"`
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = ProdURL
|
||||
}
|
||||
return &Provider{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "gemini" }
|
||||
|
||||
func (p *Provider) doPublic(ctx context.Context, path string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.cfg.BaseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("gemini %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *Provider) doPrivate(ctx context.Context, path string, payload map[string]interface{}) ([]byte, error) {
|
||||
if payload == nil {
|
||||
payload = make(map[string]interface{})
|
||||
}
|
||||
payload["request"] = "/v1" + path
|
||||
payload["nonce"] = strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
|
||||
payloadJSON, _ := json.Marshal(payload)
|
||||
b64 := base64.StdEncoding.EncodeToString(payloadJSON)
|
||||
|
||||
mac := hmac.New(sha512.New384, []byte(p.cfg.APISecret))
|
||||
mac.Write([]byte(b64))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.cfg.BaseURL+"/v1"+path, bytes.NewReader(payloadJSON))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "text/plain")
|
||||
req.Header.Set("X-GEMINI-APIKEY", p.cfg.APIKey)
|
||||
req.Header.Set("X-GEMINI-PAYLOAD", b64)
|
||||
req.Header.Set("X-GEMINI-SIGNATURE", sig)
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("gemini %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// --- Accounts ---
|
||||
|
||||
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) {
|
||||
return nil, fmt.Errorf("gemini: account creation via gemini.com")
|
||||
}
|
||||
|
||||
func (p *Provider) GetAccount(_ context.Context, _ string) (*types.Account, error) {
|
||||
return &types.Account{Provider: "gemini", ProviderID: "default", Status: "active", Currency: "USD"}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(_ context.Context) ([]*types.Account, error) {
|
||||
return []*types.Account{{Provider: "gemini", ProviderID: "default", Status: "active", Currency: "USD"}}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, _ string) (*types.Portfolio, error) {
|
||||
data, err := p.doPrivate(ctx, "/balances", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []struct {
|
||||
Currency string `json:"currency"`
|
||||
Amount string `json:"amount"`
|
||||
Available string `json:"available"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
|
||||
var positions []types.Position
|
||||
var cash string
|
||||
for _, b := range raw {
|
||||
amt, _ := strconv.ParseFloat(b.Amount, 64)
|
||||
if amt <= 0 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(b.Currency, "USD") {
|
||||
cash = fmt.Sprintf("%.2f", amt)
|
||||
continue
|
||||
}
|
||||
positions = append(positions, types.Position{
|
||||
Symbol: strings.ToUpper(b.Currency), Qty: b.Amount, AssetClass: "crypto",
|
||||
})
|
||||
}
|
||||
return &types.Portfolio{Cash: cash, Positions: positions}, nil
|
||||
}
|
||||
|
||||
// --- Orders ---
|
||||
|
||||
func (p *Provider) CreateOrder(ctx context.Context, _ string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
sym := geminiSymbol(req.Symbol)
|
||||
payload := map[string]interface{}{
|
||||
"symbol": sym,
|
||||
"amount": req.Qty,
|
||||
"side": req.Side,
|
||||
"type": "exchange " + req.Type, // Gemini: "exchange limit", "exchange market"
|
||||
}
|
||||
if req.LimitPrice != "" {
|
||||
payload["price"] = req.LimitPrice
|
||||
} else {
|
||||
// Gemini requires price even for market — use a placeholder
|
||||
payload["price"] = "1"
|
||||
payload["options"] = []string{"immediate-or-cancel"}
|
||||
}
|
||||
|
||||
data, err := p.doPrivate(ctx, "/order/new", payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
Type string `json:"type"`
|
||||
Price string `json:"price"`
|
||||
AvgPrice string `json:"avg_execution_price"`
|
||||
ExecutedAmount string `json:"executed_amount"`
|
||||
OriginalAmount string `json:"original_amount"`
|
||||
IsLive bool `json:"is_live"`
|
||||
IsCancelled bool `json:"is_cancelled"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
|
||||
status := "new"
|
||||
if raw.IsCancelled {
|
||||
status = "cancelled"
|
||||
} else if raw.ExecutedAmount == raw.OriginalAmount && raw.ExecutedAmount != "0" {
|
||||
status = "filled"
|
||||
}
|
||||
|
||||
return &types.Order{
|
||||
Provider: "gemini", ProviderID: raw.OrderID,
|
||||
Symbol: req.Symbol, Qty: raw.OriginalAmount, Side: raw.Side,
|
||||
Type: req.Type, Status: status,
|
||||
FilledQty: raw.ExecutedAmount, FilledAvgPrice: raw.AvgPrice,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, _ string) ([]*types.Order, error) {
|
||||
data, err := p.doPrivate(ctx, "/orders", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
Type string `json:"type"`
|
||||
OriginalAmount string `json:"original_amount"`
|
||||
ExecutedAmount string `json:"executed_amount"`
|
||||
Price string `json:"price"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
orders := make([]*types.Order, len(raw))
|
||||
for i, o := range raw {
|
||||
orders[i] = &types.Order{
|
||||
Provider: "gemini", ProviderID: o.OrderID,
|
||||
Symbol: o.Symbol, Side: o.Side,
|
||||
Qty: o.OriginalAmount, FilledQty: o.ExecutedAmount,
|
||||
Status: "open",
|
||||
}
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, _ string, orderID string) (*types.Order, error) {
|
||||
data, err := p.doPrivate(ctx, "/order/status", map[string]interface{}{"order_id": orderID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
OriginalAmount string `json:"original_amount"`
|
||||
ExecutedAmount string `json:"executed_amount"`
|
||||
AvgPrice string `json:"avg_execution_price"`
|
||||
IsLive bool `json:"is_live"`
|
||||
IsCancelled bool `json:"is_cancelled"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
status := "filled"
|
||||
if raw.IsLive {
|
||||
status = "open"
|
||||
}
|
||||
if raw.IsCancelled {
|
||||
status = "cancelled"
|
||||
}
|
||||
return &types.Order{
|
||||
Provider: "gemini", ProviderID: raw.OrderID,
|
||||
Symbol: raw.Symbol, Side: raw.Side,
|
||||
Qty: raw.OriginalAmount, FilledQty: raw.ExecutedAmount,
|
||||
FilledAvgPrice: raw.AvgPrice, Status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(ctx context.Context, _ string, orderID string) error {
|
||||
_, err := p.doPrivate(ctx, "/order/cancel", map[string]interface{}{"order_id": orderID})
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Stubs ---
|
||||
|
||||
func (p *Provider) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) {
|
||||
return nil, fmt.Errorf("gemini: transfers via gemini.com")
|
||||
}
|
||||
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) {
|
||||
return []*types.Transfer{}, nil
|
||||
}
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) {
|
||||
return nil, fmt.Errorf("gemini: bank linking via gemini.com")
|
||||
}
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) {
|
||||
return []*types.BankRelationship{}, nil
|
||||
}
|
||||
|
||||
// --- Assets ---
|
||||
|
||||
func (p *Provider) ListAssets(ctx context.Context, _ string) ([]*types.Asset, error) {
|
||||
data, err := p.doPublic(ctx, "/v1/symbols")
|
||||
if err != nil {
|
||||
return defaultAssets(), nil
|
||||
}
|
||||
var symbols []string
|
||||
json.Unmarshal(data, &symbols)
|
||||
assets := make([]*types.Asset, 0)
|
||||
for _, sym := range symbols {
|
||||
if !strings.HasSuffix(strings.ToLower(sym), "usd") {
|
||||
continue
|
||||
}
|
||||
base := strings.ToUpper(sym[:len(sym)-3])
|
||||
assets = append(assets, &types.Asset{
|
||||
ID: sym, Provider: "gemini", Symbol: base + "/USD",
|
||||
Class: "crypto", Status: "active", Tradable: true,
|
||||
})
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(_ context.Context, sym string) (*types.Asset, error) {
|
||||
return &types.Asset{ID: sym, Provider: "gemini", Symbol: sym, Class: "crypto", Status: "active", Tradable: true}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshot(ctx context.Context, symbol string) (*types.MarketSnapshot, error) {
|
||||
sym := geminiSymbol(symbol)
|
||||
data, err := p.doPublic(ctx, "/v1/pubticker/"+sym)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Bid string `json:"bid"`
|
||||
Ask string `json:"ask"`
|
||||
Last string `json:"last"`
|
||||
Volume struct {
|
||||
USD string `json:"USD"`
|
||||
} `json:"volume"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
bid, _ := strconv.ParseFloat(raw.Bid, 64)
|
||||
ask, _ := strconv.ParseFloat(raw.Ask, 64)
|
||||
last, _ := strconv.ParseFloat(raw.Last, 64)
|
||||
vol, _ := strconv.ParseFloat(raw.Volume.USD, 64)
|
||||
|
||||
return &types.MarketSnapshot{
|
||||
Symbol: symbol,
|
||||
LatestQuote: &types.Quote{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
BidPrice: bid, AskPrice: ask,
|
||||
},
|
||||
LatestTrade: &types.Trade{Timestamp: time.Now().UTC().Format(time.RFC3339), Price: last},
|
||||
DailyBar: &types.Bar{Close: last, Volume: vol},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshots(ctx context.Context, syms []string) (map[string]*types.MarketSnapshot, error) {
|
||||
result := make(map[string]*types.MarketSnapshot)
|
||||
for _, s := range syms {
|
||||
if snap, err := p.GetSnapshot(ctx, s); err == nil {
|
||||
result[s] = snap
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) {
|
||||
return nil, fmt.Errorf("gemini: bars not yet implemented")
|
||||
}
|
||||
func (p *Provider) GetLatestTrades(_ context.Context, _ []string) (map[string]*types.Trade, error) {
|
||||
return nil, fmt.Errorf("gemini: not yet implemented")
|
||||
}
|
||||
func (p *Provider) GetLatestQuotes(_ context.Context, _ []string) (map[string]*types.Quote, error) {
|
||||
return nil, fmt.Errorf("gemini: not yet implemented")
|
||||
}
|
||||
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) {
|
||||
return &types.MarketClock{Timestamp: time.Now().UTC().Format(time.RFC3339), IsOpen: true}, nil
|
||||
}
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) {
|
||||
return nil, fmt.Errorf("gemini: crypto 24/7")
|
||||
}
|
||||
|
||||
func geminiSymbol(sym string) string {
|
||||
s := strings.ToLower(sym)
|
||||
s = strings.ReplaceAll(s, "/", "")
|
||||
s = strings.ReplaceAll(s, "-", "")
|
||||
return s
|
||||
}
|
||||
|
||||
func defaultAssets() []*types.Asset {
|
||||
syms := []string{"BTC", "ETH", "SOL", "AVAX", "DOT", "LINK", "UNI", "LTC", "DOGE", "SHIB", "MATIC"}
|
||||
out := make([]*types.Asset, len(syms))
|
||||
for i, s := range syms {
|
||||
out[i] = &types.Asset{ID: s + "USD", Provider: "gemini", Symbol: s + "/USD", Class: "crypto", Status: "active", Tradable: true}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package ibkr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
const (
|
||||
// IBKR Client Portal API (gateway must be running locally or proxied)
|
||||
DefaultGatewayURL = "https://localhost:5000/v1/api"
|
||||
// IBKR Web API (newer, OAuth-based)
|
||||
WebAPIURL = "https://api.ibkr.com/v1/api"
|
||||
)
|
||||
|
||||
// Config for Interactive Brokers provider.
|
||||
type Config struct {
|
||||
GatewayURL string `json:"gateway_url"` // Client Portal Gateway URL
|
||||
AccountID string `json:"account_id"` // Primary account
|
||||
AccessToken string `json:"access_token"` // OAuth token (Web API)
|
||||
ConsumerKey string `json:"consumer_key"` // OAuth consumer key
|
||||
}
|
||||
|
||||
// Provider implements the broker Provider interface for IBKR.
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.GatewayURL == "" {
|
||||
cfg.GatewayURL = DefaultGatewayURL
|
||||
}
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "ibkr" }
|
||||
|
||||
func (p *Provider) do(ctx context.Context, method, path string, body interface{}) ([]byte, int, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.cfg.GatewayURL+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if p.cfg.AccessToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+p.cfg.AccessToken)
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("ibkr request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, resp.StatusCode, fmt.Errorf("ibkr %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// --- Accounts ---
|
||||
|
||||
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) {
|
||||
return nil, fmt.Errorf("ibkr: account creation not supported via API — use IBKR account management")
|
||||
}
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, providerAccountID string) (*types.Account, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/portfolio/accounts", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var accounts []struct {
|
||||
AccountID string `json:"accountId"`
|
||||
Type string `json:"type"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &accounts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range accounts {
|
||||
if a.AccountID == providerAccountID {
|
||||
return &types.Account{
|
||||
Provider: "ibkr",
|
||||
ProviderID: a.AccountID,
|
||||
Currency: a.Currency,
|
||||
AccountType: a.Type,
|
||||
Status: "active",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("ibkr: account %s not found", providerAccountID)
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/portfolio/accounts", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []struct {
|
||||
AccountID string `json:"accountId"`
|
||||
Type string `json:"type"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accts := make([]*types.Account, len(raw))
|
||||
for i, a := range raw {
|
||||
accts[i] = &types.Account{
|
||||
Provider: "ibkr",
|
||||
ProviderID: a.AccountID,
|
||||
Currency: a.Currency,
|
||||
AccountType: a.Type,
|
||||
Status: "active",
|
||||
}
|
||||
}
|
||||
return accts, nil
|
||||
}
|
||||
|
||||
// --- Portfolio ---
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, providerAccountID string) (*types.Portfolio, error) {
|
||||
// Ledger for cash/equity
|
||||
lData, _, err := p.do(ctx, http.MethodGet, "/portfolio/"+providerAccountID+"/ledger", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ledger map[string]struct {
|
||||
CashBalance float64 `json:"cashbalance"`
|
||||
NetLiquidation float64 `json:"netliquidationvalue"`
|
||||
BuyingPower float64 `json:"buyingpower"`
|
||||
}
|
||||
json.Unmarshal(lData, &ledger)
|
||||
|
||||
base := ledger["BASE"]
|
||||
|
||||
// Positions
|
||||
pData, _, err := p.do(ctx, http.MethodGet, "/portfolio/"+providerAccountID+"/positions/0", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rawPos []struct {
|
||||
Ticker string `json:"contractDesc"`
|
||||
Position float64 `json:"position"`
|
||||
AvgCost float64 `json:"avgCost"`
|
||||
MktValue float64 `json:"mktValue"`
|
||||
UnrealizedPL float64 `json:"unrealizedPnl"`
|
||||
AssetClass string `json:"assetClass"`
|
||||
}
|
||||
json.Unmarshal(pData, &rawPos)
|
||||
|
||||
positions := make([]types.Position, len(rawPos))
|
||||
for i, rp := range rawPos {
|
||||
positions[i] = types.Position{
|
||||
Symbol: rp.Ticker,
|
||||
Qty: fmt.Sprintf("%.4f", rp.Position),
|
||||
AvgEntryPrice: fmt.Sprintf("%.2f", rp.AvgCost),
|
||||
MarketValue: fmt.Sprintf("%.2f", rp.MktValue),
|
||||
UnrealizedPL: fmt.Sprintf("%.2f", rp.UnrealizedPL),
|
||||
AssetClass: rp.AssetClass,
|
||||
}
|
||||
}
|
||||
|
||||
return &types.Portfolio{
|
||||
AccountID: providerAccountID,
|
||||
Cash: fmt.Sprintf("%.2f", base.CashBalance),
|
||||
Equity: fmt.Sprintf("%.2f", base.NetLiquidation),
|
||||
BuyingPower: fmt.Sprintf("%.2f", base.BuyingPower),
|
||||
PortfolioValue: fmt.Sprintf("%.2f", base.NetLiquidation),
|
||||
Positions: positions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Orders ---
|
||||
|
||||
func (p *Provider) CreateOrder(ctx context.Context, providerAccountID string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
body := map[string]interface{}{
|
||||
"acctId": providerAccountID,
|
||||
"conid": 0, // needs contract ID lookup
|
||||
"orderType": mapOrderType(req.Type),
|
||||
"side": mapSide(req.Side),
|
||||
"tif": mapTIF(req.TimeInForce),
|
||||
"quantity": req.Qty,
|
||||
"listingExchange": "SMART",
|
||||
}
|
||||
if req.LimitPrice != "" {
|
||||
body["price"] = req.LimitPrice
|
||||
}
|
||||
|
||||
// IBKR requires a contract ID — resolve from symbol
|
||||
conid, err := p.resolveConid(ctx, req.Symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ibkr: cannot resolve symbol %s: %w", req.Symbol, err)
|
||||
}
|
||||
body["conid"] = conid
|
||||
|
||||
data, _, err := p.do(ctx, http.MethodPost, "/iserver/account/"+providerAccountID+"/orders", map[string]interface{}{
|
||||
"orders": []interface{}{body},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result []struct {
|
||||
OrderID string `json:"order_id"`
|
||||
OrderStatus string `json:"order_status"`
|
||||
}
|
||||
json.Unmarshal(data, &result)
|
||||
|
||||
if len(result) > 0 {
|
||||
return &types.Order{
|
||||
Provider: "ibkr",
|
||||
ProviderID: result[0].OrderID,
|
||||
Symbol: req.Symbol,
|
||||
Qty: req.Qty,
|
||||
Side: req.Side,
|
||||
Type: req.Type,
|
||||
Status: result[0].OrderStatus,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("ibkr: no order response")
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, _ string) ([]*types.Order, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/iserver/account/orders", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Orders []struct {
|
||||
OrderID int `json:"orderId"`
|
||||
Symbol string `json:"ticker"`
|
||||
Side string `json:"side"`
|
||||
OrderType string `json:"orderType"`
|
||||
Status string `json:"status"`
|
||||
FilledQty string `json:"filledQuantity"`
|
||||
TotalQty string `json:"totalSize"`
|
||||
} `json:"orders"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
|
||||
orders := make([]*types.Order, len(resp.Orders))
|
||||
for i, o := range resp.Orders {
|
||||
orders[i] = &types.Order{
|
||||
Provider: "ibkr",
|
||||
ProviderID: fmt.Sprintf("%d", o.OrderID),
|
||||
Symbol: o.Symbol,
|
||||
Side: o.Side,
|
||||
Type: o.OrderType,
|
||||
Status: o.Status,
|
||||
FilledQty: o.FilledQty,
|
||||
Qty: o.TotalQty,
|
||||
}
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, _ string, providerOrderID string) (*types.Order, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/iserver/account/order/status/"+providerOrderID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
OrderID int `json:"order_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
OrderType string `json:"order_type"`
|
||||
Status string `json:"order_status"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
return &types.Order{
|
||||
Provider: "ibkr",
|
||||
ProviderID: fmt.Sprintf("%d", raw.OrderID),
|
||||
Symbol: raw.Symbol,
|
||||
Side: raw.Side,
|
||||
Type: raw.OrderType,
|
||||
Status: raw.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(ctx context.Context, providerAccountID string, providerOrderID string) error {
|
||||
_, _, err := p.do(ctx, http.MethodDelete, "/iserver/account/"+providerAccountID+"/order/"+providerOrderID, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Transfers (not supported via Client Portal API) ---
|
||||
|
||||
func (p *Provider) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) {
|
||||
return nil, fmt.Errorf("ibkr: transfers not supported via API — use IBKR account management")
|
||||
}
|
||||
|
||||
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) {
|
||||
return []*types.Transfer{}, nil
|
||||
}
|
||||
|
||||
// --- Bank Relationships (not supported) ---
|
||||
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) {
|
||||
return nil, fmt.Errorf("ibkr: bank relationships not supported via API")
|
||||
}
|
||||
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) {
|
||||
return []*types.BankRelationship{}, nil
|
||||
}
|
||||
|
||||
// --- Assets ---
|
||||
|
||||
func (p *Provider) ListAssets(ctx context.Context, class string) ([]*types.Asset, error) {
|
||||
// IBKR doesn't have a "list all assets" endpoint — search by symbol
|
||||
return []*types.Asset{}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(ctx context.Context, symbolOrID string) (*types.Asset, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/iserver/secdef/search?symbol="+symbolOrID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var results []struct {
|
||||
Conid int `json:"conid"`
|
||||
Symbol string `json:"symbol"`
|
||||
CompName string `json:"companyName"`
|
||||
SecType string `json:"secType"`
|
||||
}
|
||||
json.Unmarshal(data, &results)
|
||||
if len(results) == 0 {
|
||||
return nil, fmt.Errorf("ibkr: asset %s not found", symbolOrID)
|
||||
}
|
||||
r := results[0]
|
||||
return &types.Asset{
|
||||
ID: fmt.Sprintf("%d", r.Conid),
|
||||
Provider: "ibkr",
|
||||
Symbol: r.Symbol,
|
||||
Name: r.CompName,
|
||||
Class: r.SecType,
|
||||
Status: "active",
|
||||
Tradable: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Market Data ---
|
||||
|
||||
func (p *Provider) GetSnapshot(_ context.Context, _ string) (*types.MarketSnapshot, error) {
|
||||
return nil, fmt.Errorf("ibkr: market data not yet implemented")
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshots(_ context.Context, _ []string) (map[string]*types.MarketSnapshot, error) {
|
||||
return nil, fmt.Errorf("ibkr: market data not yet implemented")
|
||||
}
|
||||
|
||||
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) {
|
||||
return nil, fmt.Errorf("ibkr: market data not yet implemented")
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestTrades(_ context.Context, _ []string) (map[string]*types.Trade, error) {
|
||||
return nil, fmt.Errorf("ibkr: market data not yet implemented")
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestQuotes(_ context.Context, _ []string) (map[string]*types.Quote, error) {
|
||||
return nil, fmt.Errorf("ibkr: market data not yet implemented")
|
||||
}
|
||||
|
||||
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) {
|
||||
return nil, fmt.Errorf("ibkr: clock not yet implemented")
|
||||
}
|
||||
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) {
|
||||
return nil, fmt.Errorf("ibkr: calendar not yet implemented")
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func (p *Provider) resolveConid(ctx context.Context, symbol string) (int, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/iserver/secdef/search?symbol="+symbol, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var results []struct {
|
||||
Conid int `json:"conid"`
|
||||
}
|
||||
json.Unmarshal(data, &results)
|
||||
if len(results) == 0 {
|
||||
return 0, fmt.Errorf("no contract found for %s", symbol)
|
||||
}
|
||||
return results[0].Conid, nil
|
||||
}
|
||||
|
||||
func mapOrderType(t string) string {
|
||||
switch t {
|
||||
case "market":
|
||||
return "MKT"
|
||||
case "limit":
|
||||
return "LMT"
|
||||
case "stop":
|
||||
return "STP"
|
||||
case "stop_limit":
|
||||
return "STP LMT"
|
||||
default:
|
||||
return "MKT"
|
||||
}
|
||||
}
|
||||
|
||||
func mapSide(s string) string {
|
||||
switch s {
|
||||
case "buy":
|
||||
return "BUY"
|
||||
case "sell":
|
||||
return "SELL"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func mapTIF(tif string) string {
|
||||
switch tif {
|
||||
case "day":
|
||||
return "DAY"
|
||||
case "gtc":
|
||||
return "GTC"
|
||||
case "ioc":
|
||||
return "IOC"
|
||||
default:
|
||||
return "DAY"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package kraken
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ProdURL = "https://api.kraken.com"
|
||||
)
|
||||
|
||||
// Config for Kraken exchange.
|
||||
type Config struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
APISecret string `json:"api_secret"` // base64-encoded
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
secret []byte
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = ProdURL
|
||||
}
|
||||
secret, _ := base64.StdEncoding.DecodeString(cfg.APISecret)
|
||||
return &Provider{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}, secret: secret}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "kraken" }
|
||||
|
||||
func (p *Provider) doPublic(ctx context.Context, path string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.cfg.BaseURL+"/0/public"+path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("kraken %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *Provider) doPrivate(ctx context.Context, path string, params url.Values) ([]byte, error) {
|
||||
fullPath := "/0/private" + path
|
||||
if params == nil {
|
||||
params = url.Values{}
|
||||
}
|
||||
nonce := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
params.Set("nonce", nonce)
|
||||
body := params.Encode()
|
||||
|
||||
// Kraken signature: HMAC-SHA512(path + SHA256(nonce + body), base64decode(secret))
|
||||
sha := sha256.Sum256([]byte(nonce + body))
|
||||
mac := hmac.New(sha512.New, p.secret)
|
||||
mac.Write(append([]byte(fullPath), sha[:]...))
|
||||
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.cfg.BaseURL+fullPath, bytes.NewReader([]byte(body)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("API-Key", p.cfg.APIKey)
|
||||
req.Header.Set("API-Sign", sig)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("kraken %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Error []string `json:"error"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
json.Unmarshal(data, &result)
|
||||
if len(result.Error) > 0 {
|
||||
return nil, fmt.Errorf("kraken: %s", strings.Join(result.Error, "; "))
|
||||
}
|
||||
return result.Result, nil
|
||||
}
|
||||
|
||||
// --- Accounts ---
|
||||
|
||||
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) {
|
||||
return nil, fmt.Errorf("kraken: account creation via kraken.com")
|
||||
}
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, _ string) (*types.Account, error) {
|
||||
return &types.Account{Provider: "kraken", ProviderID: "default", Status: "active", Currency: "USD"}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
acct, _ := p.GetAccount(ctx, "")
|
||||
return []*types.Account{acct}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, _ string) (*types.Portfolio, error) {
|
||||
data, err := p.doPrivate(ctx, "/Balance", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var balances map[string]string
|
||||
json.Unmarshal(data, &balances)
|
||||
|
||||
var positions []types.Position
|
||||
var cash string
|
||||
for asset, bal := range balances {
|
||||
f, _ := strconv.ParseFloat(bal, 64)
|
||||
if f <= 0 {
|
||||
continue
|
||||
}
|
||||
if asset == "ZUSD" || asset == "USD" {
|
||||
cash = fmt.Sprintf("%.2f", f)
|
||||
continue
|
||||
}
|
||||
positions = append(positions, types.Position{
|
||||
Symbol: normalizeKrakenAsset(asset), Qty: bal, AssetClass: "crypto",
|
||||
})
|
||||
}
|
||||
return &types.Portfolio{Cash: cash, Positions: positions}, nil
|
||||
}
|
||||
|
||||
// --- Orders ---
|
||||
|
||||
func (p *Provider) CreateOrder(ctx context.Context, _ string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
params := url.Values{}
|
||||
params.Set("pair", krakenPair(req.Symbol))
|
||||
params.Set("type", req.Side)
|
||||
params.Set("ordertype", req.Type)
|
||||
params.Set("volume", req.Qty)
|
||||
if req.LimitPrice != "" {
|
||||
params.Set("price", req.LimitPrice)
|
||||
}
|
||||
|
||||
data, err := p.doPrivate(ctx, "/AddOrder", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Descr struct{ Order string `json:"order"` } `json:"descr"`
|
||||
TxID []string `json:"txid"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
orderID := ""
|
||||
if len(raw.TxID) > 0 {
|
||||
orderID = raw.TxID[0]
|
||||
}
|
||||
return &types.Order{
|
||||
Provider: "kraken", ProviderID: orderID,
|
||||
Symbol: req.Symbol, Qty: req.Qty, Side: req.Side,
|
||||
Type: req.Type, Status: "new", CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, _ string) ([]*types.Order, error) {
|
||||
data, err := p.doPrivate(ctx, "/OpenOrders", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
Open map[string]struct {
|
||||
Descr struct {
|
||||
Pair string `json:"pair"`
|
||||
Type string `json:"type"`
|
||||
Order string `json:"ordertype"`
|
||||
} `json:"descr"`
|
||||
Vol string `json:"vol"`
|
||||
VolExec string `json:"vol_exec"`
|
||||
Status string `json:"status"`
|
||||
} `json:"open"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
orders := make([]*types.Order, 0, len(raw.Open))
|
||||
for id, o := range raw.Open {
|
||||
orders = append(orders, &types.Order{
|
||||
Provider: "kraken", ProviderID: id,
|
||||
Symbol: o.Descr.Pair, Side: o.Descr.Type, Type: o.Descr.Order,
|
||||
Qty: o.Vol, FilledQty: o.VolExec, Status: o.Status,
|
||||
})
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, _ string, orderID string) (*types.Order, error) {
|
||||
params := url.Values{}
|
||||
params.Set("txid", orderID)
|
||||
data, err := p.doPrivate(ctx, "/QueryOrders", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw map[string]struct {
|
||||
Descr struct {
|
||||
Pair string `json:"pair"`
|
||||
Type string `json:"type"`
|
||||
} `json:"descr"`
|
||||
Vol string `json:"vol"`
|
||||
VolExec string `json:"vol_exec"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
if o, ok := raw[orderID]; ok {
|
||||
return &types.Order{
|
||||
Provider: "kraken", ProviderID: orderID,
|
||||
Symbol: o.Descr.Pair, Side: o.Descr.Type,
|
||||
Qty: o.Vol, FilledQty: o.VolExec, Status: o.Status,
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("kraken: order %s not found", orderID)
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(ctx context.Context, _ string, orderID string) error {
|
||||
params := url.Values{}
|
||||
params.Set("txid", orderID)
|
||||
_, err := p.doPrivate(ctx, "/CancelOrder", params)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Stubs ---
|
||||
|
||||
func (p *Provider) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) {
|
||||
return nil, fmt.Errorf("kraken: transfers via kraken.com")
|
||||
}
|
||||
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) {
|
||||
return []*types.Transfer{}, nil
|
||||
}
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) {
|
||||
return nil, fmt.Errorf("kraken: bank linking via kraken.com")
|
||||
}
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) {
|
||||
return []*types.BankRelationship{}, nil
|
||||
}
|
||||
|
||||
// --- Assets ---
|
||||
|
||||
func (p *Provider) ListAssets(ctx context.Context, _ string) ([]*types.Asset, error) {
|
||||
data, err := p.doPublic(ctx, "/AssetPairs")
|
||||
if err != nil {
|
||||
return defaultAssets(), nil
|
||||
}
|
||||
var resp struct {
|
||||
Error []string `json:"error"`
|
||||
Result map[string]struct {
|
||||
Base string `json:"base"`
|
||||
Quote string `json:"quote"`
|
||||
} `json:"result"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
assets := make([]*types.Asset, 0)
|
||||
for pair, info := range resp.Result {
|
||||
q := normalizeKrakenAsset(info.Quote)
|
||||
if q != "USD" && q != "USDT" {
|
||||
continue
|
||||
}
|
||||
b := normalizeKrakenAsset(info.Base)
|
||||
assets = append(assets, &types.Asset{
|
||||
ID: pair, Provider: "kraken", Symbol: b + "/" + q,
|
||||
Class: "crypto", Status: "active", Tradable: true,
|
||||
})
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(_ context.Context, sym string) (*types.Asset, error) {
|
||||
return &types.Asset{ID: sym, Provider: "kraken", Symbol: sym, Class: "crypto", Status: "active", Tradable: true}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshot(ctx context.Context, symbol string) (*types.MarketSnapshot, error) {
|
||||
pair := krakenPair(symbol)
|
||||
data, err := p.doPublic(ctx, "/Ticker?pair="+pair)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
Error []string `json:"error"`
|
||||
Result map[string]struct {
|
||||
A []string `json:"a"` // ask [price, whole_lot_volume, lot_volume]
|
||||
B []string `json:"b"` // bid
|
||||
C []string `json:"c"` // last trade closed [price, lot_volume]
|
||||
V []string `json:"v"` // volume [today, 24h]
|
||||
H []string `json:"h"` // high
|
||||
L []string `json:"l"` // low
|
||||
O string `json:"o"` // open
|
||||
} `json:"result"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
for _, t := range resp.Result {
|
||||
bid, _ := strconv.ParseFloat(t.B[0], 64)
|
||||
ask, _ := strconv.ParseFloat(t.A[0], 64)
|
||||
last, _ := strconv.ParseFloat(t.C[0], 64)
|
||||
vol, _ := strconv.ParseFloat(t.V[1], 64)
|
||||
hi, _ := strconv.ParseFloat(t.H[1], 64)
|
||||
lo, _ := strconv.ParseFloat(t.L[1], 64)
|
||||
op, _ := strconv.ParseFloat(t.O, 64)
|
||||
return &types.MarketSnapshot{
|
||||
Symbol: symbol,
|
||||
LatestQuote: &types.Quote{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
BidPrice: bid, AskPrice: ask,
|
||||
},
|
||||
LatestTrade: &types.Trade{Timestamp: time.Now().UTC().Format(time.RFC3339), Price: last},
|
||||
DailyBar: &types.Bar{Open: op, High: hi, Low: lo, Close: last, Volume: vol},
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("kraken: no data for %s", symbol)
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshots(ctx context.Context, syms []string) (map[string]*types.MarketSnapshot, error) {
|
||||
result := make(map[string]*types.MarketSnapshot)
|
||||
for _, s := range syms {
|
||||
if snap, err := p.GetSnapshot(ctx, s); err == nil {
|
||||
result[s] = snap
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) {
|
||||
return nil, fmt.Errorf("kraken: bars not yet implemented")
|
||||
}
|
||||
func (p *Provider) GetLatestTrades(_ context.Context, _ []string) (map[string]*types.Trade, error) {
|
||||
return nil, fmt.Errorf("kraken: not yet implemented")
|
||||
}
|
||||
func (p *Provider) GetLatestQuotes(_ context.Context, _ []string) (map[string]*types.Quote, error) {
|
||||
return nil, fmt.Errorf("kraken: not yet implemented")
|
||||
}
|
||||
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) {
|
||||
return &types.MarketClock{Timestamp: time.Now().UTC().Format(time.RFC3339), IsOpen: true}, nil
|
||||
}
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) {
|
||||
return nil, fmt.Errorf("kraken: crypto 24/7")
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func krakenPair(sym string) string {
|
||||
s := strings.ToUpper(sym)
|
||||
s = strings.ReplaceAll(s, "/", "")
|
||||
s = strings.ReplaceAll(s, "-", "")
|
||||
return s
|
||||
}
|
||||
|
||||
func normalizeKrakenAsset(a string) string {
|
||||
a = strings.TrimPrefix(a, "X")
|
||||
a = strings.TrimPrefix(a, "Z")
|
||||
return strings.ToUpper(a)
|
||||
}
|
||||
|
||||
func defaultAssets() []*types.Asset {
|
||||
syms := []string{"BTC", "ETH", "SOL", "AVAX", "DOT", "LINK", "UNI", "LTC", "XRP", "ADA", "DOGE", "ATOM"}
|
||||
out := make([]*types.Asset, len(syms))
|
||||
for i, s := range syms {
|
||||
out[i] = &types.Asset{ID: s + "USD", Provider: "kraken", Symbol: s + "/USD", Class: "crypto", Status: "active", Tradable: true}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package lmax
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
// LMAX Digital — Institutional-grade FX and crypto exchange.
|
||||
// Central Limit Order Book (CLOB), FCA regulated, sub-millisecond matching.
|
||||
// Supports spot FX, crypto/USD pairs, and precious metals.
|
||||
|
||||
const (
|
||||
ProdURL = "https://trade.lmax.com"
|
||||
SandboxURL = "https://web-order.london-demo.lmax.com"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
Username string
|
||||
Password string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
sessionID string
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" { cfg.BaseURL = ProdURL }
|
||||
return &Provider{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "lmax" }
|
||||
|
||||
var errNotSupported = fmt.Errorf("not supported by lmax")
|
||||
|
||||
func (p *Provider) doRequest(ctx context.Context, method, path string, body interface{}) ([]byte, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil { b, _ := json.Marshal(body); reqBody = bytes.NewReader(b) }
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, reqBody)
|
||||
if err != nil { return nil, err }
|
||||
if p.cfg.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
||||
}
|
||||
if p.sessionID != "" {
|
||||
req.Header.Set("Cookie", "JSESSIONID="+p.sessionID)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil { return nil, err }
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 { return nil, fmt.Errorf("lmax error %d: %s", resp.StatusCode, string(data)) }
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// LMAX accounts are managed externally
|
||||
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) { return nil, errNotSupported }
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, id string) (*types.Account, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/api/account", nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ AccountID string `json:"accountId"`; Status string `json:"status"`; Currency string `json:"currency"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Account{ID: resp.AccountID, Provider: "lmax", ProviderID: resp.AccountID, Status: resp.Status, Currency: resp.Currency}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
acct, err := p.GetAccount(ctx, "")
|
||||
if err != nil { return nil, err }
|
||||
return []*types.Account{acct}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, id string) (*types.Portfolio, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/api/account/balance", nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Cash float64 `json:"cash"`; Equity float64 `json:"equity"`; UnrealizedPL float64 `json:"unrealisedProfitAndLoss"`; Margin float64 `json:"margin"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Portfolio{
|
||||
AccountID: id,
|
||||
Cash: fmt.Sprintf("%.2f", resp.Cash),
|
||||
Equity: fmt.Sprintf("%.2f", resp.Equity),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CreateOrder(ctx context.Context, accountID string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
body := map[string]interface{}{
|
||||
"instrumentId": req.Symbol,
|
||||
"quantity": req.Qty,
|
||||
"timeInForce": req.TimeInForce,
|
||||
}
|
||||
if req.Type == "limit" {
|
||||
body["price"] = req.LimitPrice
|
||||
body["type"] = "LIMIT"
|
||||
} else {
|
||||
body["type"] = "MARKET"
|
||||
}
|
||||
|
||||
data, err := p.doRequest(ctx, "POST", "/api/order", body)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ OrderID string `json:"orderId"`; Status string `json:"status"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Order{ID: resp.OrderID, Provider: "lmax", ProviderID: resp.OrderID, AccountID: accountID, Symbol: req.Symbol, Qty: req.Qty, Side: req.Side, Type: req.Type, Status: resp.Status, CreatedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, accountID string) ([]*types.Order, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/api/order/active", nil)
|
||||
if err != nil { return nil, err }
|
||||
var items []struct{ OrderID string `json:"orderId"`; InstrumentID string `json:"instrumentId"`; Status string `json:"status"` }
|
||||
json.Unmarshal(data, &items)
|
||||
orders := make([]*types.Order, 0, len(items))
|
||||
for _, item := range items {
|
||||
orders = append(orders, &types.Order{ID: item.OrderID, Provider: "lmax", ProviderID: item.OrderID, Symbol: item.InstrumentID, Status: item.Status})
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, accountID, orderID string) (*types.Order, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/api/order/"+orderID, nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ OrderID string `json:"orderId"`; Status string `json:"status"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Order{ID: resp.OrderID, Provider: "lmax", ProviderID: resp.OrderID, Status: resp.Status}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(ctx context.Context, accountID, orderID string) error {
|
||||
_, err := p.doRequest(ctx, "DELETE", "/api/order/"+orderID, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *Provider) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) { return nil, errNotSupported }
|
||||
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) { return nil, errNotSupported }
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) { return nil, errNotSupported }
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) { return nil, errNotSupported }
|
||||
|
||||
// LMAX instruments
|
||||
func (p *Provider) ListAssets(_ context.Context, class string) ([]*types.Asset, error) {
|
||||
instruments := []struct{ sym, name, cls string }{
|
||||
// Spot FX
|
||||
{"EURUSD", "EUR/USD", "forex"}, {"GBPUSD", "GBP/USD", "forex"}, {"USDJPY", "USD/JPY", "forex"},
|
||||
{"USDCHF", "USD/CHF", "forex"}, {"AUDUSD", "AUD/USD", "forex"}, {"USDCAD", "USD/CAD", "forex"},
|
||||
{"NZDUSD", "NZD/USD", "forex"}, {"EURGBP", "EUR/GBP", "forex"}, {"EURJPY", "EUR/JPY", "forex"},
|
||||
// Crypto
|
||||
{"BTCUSD", "BTC/USD", "crypto"}, {"ETHUSD", "ETH/USD", "crypto"}, {"LTCUSD", "LTC/USD", "crypto"},
|
||||
{"XRPUSD", "XRP/USD", "crypto"}, {"BCHUSD", "BCH/USD", "crypto"}, {"SOLUSD", "SOL/USD", "crypto"},
|
||||
// Precious metals
|
||||
{"XAUUSD", "Gold/USD", "commodity"}, {"XAGUSD", "Silver/USD", "commodity"},
|
||||
}
|
||||
assets := make([]*types.Asset, 0)
|
||||
for _, i := range instruments {
|
||||
if class != "" && i.cls != class { continue }
|
||||
assets = append(assets, &types.Asset{ID: i.sym, Provider: "lmax", Symbol: i.sym, Name: i.name, Class: i.cls, Status: "active", Tradable: true})
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(_ context.Context, sym string) (*types.Asset, error) {
|
||||
return &types.Asset{ID: sym, Provider: "lmax", Symbol: sym, Class: "forex", Status: "active", Tradable: true}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshot(ctx context.Context, symbol string) (*types.MarketSnapshot, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/api/marketdata/orderbook/"+symbol, nil)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct {
|
||||
Bids []struct{ Price float64 `json:"price"`; Qty float64 `json:"quantity"` } `json:"bids"`
|
||||
Asks []struct{ Price float64 `json:"price"`; Qty float64 `json:"quantity"` } `json:"asks"`
|
||||
}
|
||||
json.Unmarshal(data, &resp)
|
||||
snap := &types.MarketSnapshot{Symbol: symbol}
|
||||
if len(resp.Bids) > 0 && len(resp.Asks) > 0 {
|
||||
snap.LatestQuote = &types.Quote{BidPrice: resp.Bids[0].Price, BidSize: resp.Bids[0].Qty, AskPrice: resp.Asks[0].Price, AskSize: resp.Asks[0].Qty}
|
||||
mid := (resp.Bids[0].Price + resp.Asks[0].Price) / 2
|
||||
snap.LatestTrade = &types.Trade{Price: mid}
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshots(ctx context.Context, symbols []string) (map[string]*types.MarketSnapshot, error) {
|
||||
result := make(map[string]*types.MarketSnapshot)
|
||||
for _, sym := range symbols {
|
||||
snap, err := p.GetSnapshot(ctx, sym)
|
||||
if err == nil { result[sym] = snap }
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) { return nil, errNotSupported }
|
||||
|
||||
func (p *Provider) GetLatestTrades(ctx context.Context, symbols []string) (map[string]*types.Trade, error) {
|
||||
snaps, err := p.GetSnapshots(ctx, symbols)
|
||||
if err != nil { return nil, err }
|
||||
result := make(map[string]*types.Trade)
|
||||
for sym, snap := range snaps { if snap.LatestTrade != nil { result[sym] = snap.LatestTrade } }
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestQuotes(ctx context.Context, symbols []string) (map[string]*types.Quote, error) {
|
||||
snaps, err := p.GetSnapshots(ctx, symbols)
|
||||
if err != nil { return nil, err }
|
||||
result := make(map[string]*types.Quote)
|
||||
for sym, snap := range snaps { if snap.LatestQuote != nil { result[sym] = snap.LatestQuote } }
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) {
|
||||
// FX/Crypto: 24/5 for FX, 24/7 for crypto
|
||||
now := time.Now().UTC()
|
||||
return &types.MarketClock{IsOpen: true, Timestamp: now.Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) { return nil, errNotSupported }
|
||||
@@ -0,0 +1,187 @@
|
||||
package polygon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
// Polygon.io — Market data aggregator for stocks, options, forex, crypto.
|
||||
// Read-only provider: no account/order support, market data only.
|
||||
|
||||
const ProdURL = "https://api.polygon.io"
|
||||
|
||||
type Config struct{ BaseURL, APIKey string }
|
||||
|
||||
type Provider struct{ cfg Config; client *http.Client }
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" { cfg.BaseURL = ProdURL }
|
||||
return &Provider{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "polygon" }
|
||||
|
||||
var errNotSupported = fmt.Errorf("not supported by polygon (market data only)")
|
||||
|
||||
func (p *Provider) doRequest(ctx context.Context, path string) ([]byte, error) {
|
||||
sep := "?"
|
||||
if strings.Contains(path, "?") { sep = "&" }
|
||||
url := p.cfg.BaseURL + path + sep + "apiKey=" + p.cfg.APIKey
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil { return nil, err }
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil { return nil, err }
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 { return nil, fmt.Errorf("polygon error %d: %s", resp.StatusCode, string(data)) }
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Account/Order — not supported (market data only)
|
||||
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetAccount(_ context.Context, _ string) (*types.Account, error) { return nil, errNotSupported }
|
||||
func (p *Provider) ListAccounts(_ context.Context) ([]*types.Account, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetPortfolio(_ context.Context, _ string) (*types.Portfolio, error) { return nil, errNotSupported }
|
||||
func (p *Provider) CreateOrder(_ context.Context, _ string, _ *types.CreateOrderRequest) (*types.Order, error) { return nil, errNotSupported }
|
||||
func (p *Provider) ListOrders(_ context.Context, _ string) ([]*types.Order, error) { return nil, errNotSupported }
|
||||
func (p *Provider) GetOrder(_ context.Context, _, _ string) (*types.Order, error) { return nil, errNotSupported }
|
||||
func (p *Provider) CancelOrder(_ context.Context, _, _ string) error { return errNotSupported }
|
||||
func (p *Provider) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) { return nil, errNotSupported }
|
||||
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) { return nil, errNotSupported }
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) { return nil, errNotSupported }
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) { return nil, errNotSupported }
|
||||
|
||||
// Assets
|
||||
func (p *Provider) ListAssets(ctx context.Context, class string) ([]*types.Asset, error) {
|
||||
market := "stocks"
|
||||
if class == "crypto" { market = "crypto" }
|
||||
if class == "forex" || class == "fx" { market = "fx" }
|
||||
data, err := p.doRequest(ctx, "/v3/reference/tickers?market="+market+"&active=true&limit=100")
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Results []struct{ Ticker string `json:"ticker"`; Name string `json:"name"`; Market string `json:"market"` } `json:"results"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
assets := make([]*types.Asset, 0, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
c := "us_equity"
|
||||
if r.Market == "crypto" { c = "crypto" } else if r.Market == "fx" { c = "forex" }
|
||||
assets = append(assets, &types.Asset{ID: r.Ticker, Provider: "polygon", Symbol: r.Ticker, Name: r.Name, Class: c, Status: "active", Tradable: false})
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(ctx context.Context, sym string) (*types.Asset, error) {
|
||||
data, err := p.doRequest(ctx, "/v3/reference/tickers/"+sym)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Results struct{ Ticker string `json:"ticker"`; Name string `json:"name"`; Market string `json:"market"` } `json:"results"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
c := "us_equity"
|
||||
if resp.Results.Market == "crypto" { c = "crypto" } else if resp.Results.Market == "fx" { c = "forex" }
|
||||
return &types.Asset{ID: resp.Results.Ticker, Provider: "polygon", Symbol: resp.Results.Ticker, Name: resp.Results.Name, Class: c, Status: "active", Tradable: false}, nil
|
||||
}
|
||||
|
||||
// Market Data — primary value of Polygon
|
||||
func (p *Provider) GetSnapshot(ctx context.Context, symbol string) (*types.MarketSnapshot, error) {
|
||||
data, err := p.doRequest(ctx, "/v2/snapshot/locale/us/markets/stocks/tickers/"+symbol)
|
||||
if err != nil {
|
||||
// Try crypto
|
||||
data, err = p.doRequest(ctx, "/v2/snapshot/locale/global/markets/crypto/tickers/X:"+symbol+"USD")
|
||||
if err != nil { return nil, err }
|
||||
}
|
||||
var resp struct{ Ticker struct {
|
||||
LastTrade struct{ P float64 `json:"p"`; S float64 `json:"s"` } `json:"lastTrade"`
|
||||
LastQuote struct{ P float64 `json:"P"`; S float64 `json:"S"`; Bp float64 `json:"p"`; Bs float64 `json:"s"` } `json:"lastQuote"`
|
||||
Day struct{ O float64 `json:"o"`; H float64 `json:"h"`; L float64 `json:"l"`; C float64 `json:"c"`; V float64 `json:"v"`; VW float64 `json:"vw"` } `json:"day"`
|
||||
PrevDay struct{ O float64 `json:"o"`; H float64 `json:"h"`; L float64 `json:"l"`; C float64 `json:"c"`; V float64 `json:"v"` } `json:"prevDay"`
|
||||
} `json:"ticker"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
t := resp.Ticker
|
||||
snap := &types.MarketSnapshot{
|
||||
Symbol: symbol,
|
||||
LatestTrade: &types.Trade{Price: t.LastTrade.P, Size: t.LastTrade.S},
|
||||
LatestQuote: &types.Quote{BidPrice: t.LastQuote.Bp, BidSize: t.LastQuote.Bs, AskPrice: t.LastQuote.P, AskSize: t.LastQuote.S},
|
||||
DailyBar: &types.Bar{Open: t.Day.O, High: t.Day.H, Low: t.Day.L, Close: t.Day.C, Volume: t.Day.V, VWAP: t.Day.VW},
|
||||
PrevDailyBar: &types.Bar{Open: t.PrevDay.O, High: t.PrevDay.H, Low: t.PrevDay.L, Close: t.PrevDay.C, Volume: t.PrevDay.V},
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshots(ctx context.Context, symbols []string) (map[string]*types.MarketSnapshot, error) {
|
||||
result := make(map[string]*types.MarketSnapshot)
|
||||
for _, sym := range symbols {
|
||||
snap, err := p.GetSnapshot(ctx, sym)
|
||||
if err == nil { result[sym] = snap }
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetBars(ctx context.Context, symbol, timeframe, start, end string, limit int) ([]*types.Bar, error) {
|
||||
multiplier := "1"
|
||||
span := "day"
|
||||
switch timeframe {
|
||||
case "1Min", "1m": multiplier, span = "1", "minute"
|
||||
case "5Min", "5m": multiplier, span = "5", "minute"
|
||||
case "15Min", "15m": multiplier, span = "15", "minute"
|
||||
case "1Hour", "1h": multiplier, span = "1", "hour"
|
||||
case "1Day", "1d": multiplier, span = "1", "day"
|
||||
case "1Week", "1w": multiplier, span = "1", "week"
|
||||
}
|
||||
path := fmt.Sprintf("/v2/aggs/ticker/%s/range/%s/%s/%s/%s?limit=%d", symbol, multiplier, span, start, end, limit)
|
||||
data, err := p.doRequest(ctx, path)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Results []struct{ T int64 `json:"t"`; O float64 `json:"o"`; H float64 `json:"h"`; L float64 `json:"l"`; C float64 `json:"c"`; V float64 `json:"v"`; VW float64 `json:"vw"`; N int `json:"n"` } `json:"results"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
bars := make([]*types.Bar, 0, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
bars = append(bars, &types.Bar{
|
||||
Timestamp: time.UnixMilli(r.T).Format(time.RFC3339),
|
||||
Open: r.O, High: r.H, Low: r.L, Close: r.C, Volume: r.V, VWAP: r.VW, TradeCount: r.N,
|
||||
})
|
||||
}
|
||||
return bars, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestTrades(ctx context.Context, symbols []string) (map[string]*types.Trade, error) {
|
||||
result := make(map[string]*types.Trade)
|
||||
for _, sym := range symbols {
|
||||
data, err := p.doRequest(ctx, "/v2/last/trade/"+sym)
|
||||
if err != nil { continue }
|
||||
var resp struct{ Results struct{ P float64 `json:"p"`; S float64 `json:"s"`; T int64 `json:"t"` } `json:"results"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
result[sym] = &types.Trade{Price: resp.Results.P, Size: resp.Results.S, Timestamp: time.UnixMilli(resp.Results.T).Format(time.RFC3339)}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestQuotes(ctx context.Context, symbols []string) (map[string]*types.Quote, error) {
|
||||
result := make(map[string]*types.Quote)
|
||||
for _, sym := range symbols {
|
||||
data, err := p.doRequest(ctx, "/v3/quotes/"+sym+"?limit=1&order=desc")
|
||||
if err != nil { continue }
|
||||
var resp struct{ Results []struct{ BP float64 `json:"bid_price"`; BS float64 `json:"bid_size"`; AP float64 `json:"ask_price"`; AS float64 `json:"ask_size"` } `json:"results"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
if len(resp.Results) > 0 {
|
||||
r := resp.Results[0]
|
||||
result[sym] = &types.Quote{BidPrice: r.BP, BidSize: r.BS, AskPrice: r.AP, AskSize: r.AS}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetClock(ctx context.Context) (*types.MarketClock, error) {
|
||||
data, err := p.doRequest(ctx, "/v1/marketstatus/now")
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Market string `json:"market"`; ServerTime string `json:"serverTime"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.MarketClock{IsOpen: resp.Market == "open", Timestamp: resp.ServerTime}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) {
|
||||
return nil, errNotSupported
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
// Provider is the unified interface every broker backend must implement.
|
||||
type Provider interface {
|
||||
// Name returns the provider identifier (e.g. "alpaca", "ibkr").
|
||||
Name() string
|
||||
|
||||
// Accounts
|
||||
CreateAccount(ctx context.Context, req *types.CreateAccountRequest) (*types.Account, error)
|
||||
GetAccount(ctx context.Context, providerAccountID string) (*types.Account, error)
|
||||
ListAccounts(ctx context.Context) ([]*types.Account, error)
|
||||
|
||||
// Portfolio & Positions
|
||||
GetPortfolio(ctx context.Context, providerAccountID string) (*types.Portfolio, error)
|
||||
|
||||
// Orders
|
||||
CreateOrder(ctx context.Context, providerAccountID string, req *types.CreateOrderRequest) (*types.Order, error)
|
||||
ListOrders(ctx context.Context, providerAccountID string) ([]*types.Order, error)
|
||||
GetOrder(ctx context.Context, providerAccountID, providerOrderID string) (*types.Order, error)
|
||||
CancelOrder(ctx context.Context, providerAccountID, providerOrderID string) error
|
||||
|
||||
// Transfers
|
||||
CreateTransfer(ctx context.Context, providerAccountID string, req *types.CreateTransferRequest) (*types.Transfer, error)
|
||||
ListTransfers(ctx context.Context, providerAccountID string) ([]*types.Transfer, error)
|
||||
|
||||
// Bank Relationships
|
||||
CreateBankRelationship(ctx context.Context, providerAccountID string, ownerName, accountType, accountNumber, routingNumber string) (*types.BankRelationship, error)
|
||||
ListBankRelationships(ctx context.Context, providerAccountID string) ([]*types.BankRelationship, error)
|
||||
|
||||
// Assets
|
||||
ListAssets(ctx context.Context, class string) ([]*types.Asset, error)
|
||||
GetAsset(ctx context.Context, symbolOrID string) (*types.Asset, error)
|
||||
|
||||
// Market Data
|
||||
GetSnapshot(ctx context.Context, symbol string) (*types.MarketSnapshot, error)
|
||||
GetSnapshots(ctx context.Context, symbols []string) (map[string]*types.MarketSnapshot, error)
|
||||
GetBars(ctx context.Context, symbol, timeframe, start, end string, limit int) ([]*types.Bar, error)
|
||||
GetLatestTrades(ctx context.Context, symbols []string) (map[string]*types.Trade, error)
|
||||
GetLatestQuotes(ctx context.Context, symbols []string) (map[string]*types.Quote, error)
|
||||
GetClock(ctx context.Context) (*types.MarketClock, error)
|
||||
GetCalendar(ctx context.Context, start, end string) ([]*types.MarketCalendarDay, error)
|
||||
}
|
||||
|
||||
// Registry holds all registered providers.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
providers map[string]Provider
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{providers: make(map[string]Provider)}
|
||||
}
|
||||
|
||||
func (r *Registry) Register(p Provider) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.providers[p.Name()] = p
|
||||
}
|
||||
|
||||
func (r *Registry) Get(name string) (Provider, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
p, ok := r.providers[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("provider %q not registered", name)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *Registry) List() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
names := make([]string, 0, len(r.providers))
|
||||
for n := range r.providers {
|
||||
names = append(names, n)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
package sfox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ProdURL = "https://api.sfox.com/v1"
|
||||
SandboxURL = "https://api.sandbox.sfox.com/v1"
|
||||
)
|
||||
|
||||
// Config for SFOX crypto prime dealer.
|
||||
// SFOX aggregates liquidity from 30+ exchanges and OTC desks.
|
||||
// Supports: smart order routing, algorithmic execution (TWAP/VWAP),
|
||||
// OTC block trading, and settlement.
|
||||
type Config struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
// Provider implements the broker Provider interface for SFOX.
|
||||
type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = ProdURL
|
||||
}
|
||||
return &Provider{
|
||||
cfg: cfg,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "sfox" }
|
||||
|
||||
func (p *Provider) do(ctx context.Context, method, path string, body interface{}) ([]byte, int, error) {
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, resp.StatusCode, fmt.Errorf("sfox %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
return data, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// --- Accounts ---
|
||||
|
||||
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) {
|
||||
return nil, fmt.Errorf("sfox: account creation handled via SFOX onboarding portal")
|
||||
}
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, _ string) (*types.Account, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/account", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
ID int `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
return &types.Account{
|
||||
Provider: "sfox",
|
||||
ProviderID: strconv.Itoa(raw.ID),
|
||||
Status: raw.Status,
|
||||
Currency: "USD",
|
||||
Contact: &types.Contact{Email: raw.Email},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
acct, err := p.GetAccount(ctx, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*types.Account{acct}, nil
|
||||
}
|
||||
|
||||
// --- Portfolio ---
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, _ string) (*types.Portfolio, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/user/balance", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// SFOX returns { "btc": {"balance": ..., "available": ...}, "usd": {...}, ... }
|
||||
var balances map[string]struct {
|
||||
Balance float64 `json:"balance"`
|
||||
Available float64 `json:"available"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &balances); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var positions []types.Position
|
||||
var cash string
|
||||
for coin, bal := range balances {
|
||||
if strings.EqualFold(coin, "usd") {
|
||||
cash = fmt.Sprintf("%.2f", bal.Balance)
|
||||
continue
|
||||
}
|
||||
if bal.Balance > 0 {
|
||||
positions = append(positions, types.Position{
|
||||
Symbol: strings.ToUpper(coin),
|
||||
Qty: fmt.Sprintf("%.8f", bal.Balance),
|
||||
AssetClass: "crypto",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &types.Portfolio{
|
||||
Cash: cash,
|
||||
Positions: positions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Orders ---
|
||||
|
||||
func (p *Provider) CreateOrder(ctx context.Context, _ string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
// SFOX supports: market, limit, and algo orders (TWAP, VWAP, Polar, Iceberg)
|
||||
body := map[string]interface{}{
|
||||
"pair": normalizePair(req.Symbol),
|
||||
"side": req.Side,
|
||||
"type": mapOrderType(req.Type),
|
||||
"quantity": req.Qty,
|
||||
}
|
||||
if req.LimitPrice != "" {
|
||||
body["price"] = req.LimitPrice
|
||||
}
|
||||
|
||||
data, _, err := p.do(ctx, http.MethodPost, "/orders", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
ID int `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Price string `json:"price"`
|
||||
Filled string `json:"filled"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
|
||||
return &types.Order{
|
||||
Provider: "sfox",
|
||||
ProviderID: strconv.Itoa(raw.ID),
|
||||
Symbol: req.Symbol,
|
||||
Qty: req.Qty,
|
||||
Side: req.Side,
|
||||
Type: req.Type,
|
||||
Status: mapStatus(raw.Status),
|
||||
FilledAvgPrice: raw.Price,
|
||||
FilledQty: raw.Filled,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, _ string) ([]*types.Order, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/orders", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []struct {
|
||||
ID int `json:"id"`
|
||||
Pair string `json:"pair"`
|
||||
Side string `json:"side"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Quantity string `json:"quantity"`
|
||||
Price string `json:"price"`
|
||||
Filled string `json:"filled"`
|
||||
CreatedAt string `json:"date_added"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
|
||||
orders := make([]*types.Order, len(raw))
|
||||
for i, o := range raw {
|
||||
orders[i] = &types.Order{
|
||||
Provider: "sfox",
|
||||
ProviderID: strconv.Itoa(o.ID),
|
||||
Symbol: o.Pair,
|
||||
Side: o.Side,
|
||||
Type: o.Type,
|
||||
Status: mapStatus(o.Status),
|
||||
Qty: o.Quantity,
|
||||
FilledQty: o.Filled,
|
||||
FilledAvgPrice: o.Price,
|
||||
}
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, _ string, orderID string) (*types.Order, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/orders/"+orderID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
ID int `json:"id"`
|
||||
Pair string `json:"pair"`
|
||||
Side string `json:"side"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Quantity string `json:"quantity"`
|
||||
Price string `json:"price"`
|
||||
Filled string `json:"filled"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
return &types.Order{
|
||||
Provider: "sfox",
|
||||
ProviderID: strconv.Itoa(raw.ID),
|
||||
Symbol: raw.Pair,
|
||||
Side: raw.Side,
|
||||
Type: raw.Type,
|
||||
Status: mapStatus(raw.Status),
|
||||
Qty: raw.Quantity,
|
||||
FilledQty: raw.Filled,
|
||||
FilledAvgPrice: raw.Price,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(ctx context.Context, _ string, orderID string) error {
|
||||
_, _, err := p.do(ctx, http.MethodDelete, "/orders/"+orderID, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Transfers ---
|
||||
|
||||
func (p *Provider) CreateTransfer(ctx context.Context, _ string, req *types.CreateTransferRequest) (*types.Transfer, error) {
|
||||
if req.Direction == "outgoing" {
|
||||
// Crypto withdrawal
|
||||
body := map[string]interface{}{
|
||||
"currency": req.Type,
|
||||
"amount": req.Amount,
|
||||
"address": req.RelationshipID,
|
||||
}
|
||||
data, _, err := p.do(ctx, http.MethodPost, "/user/withdraw", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
return &types.Transfer{
|
||||
Provider: "sfox",
|
||||
ProviderID: raw.ID,
|
||||
Type: "crypto",
|
||||
Direction: "outgoing",
|
||||
Amount: req.Amount,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("sfox: incoming transfers via SFOX dashboard")
|
||||
}
|
||||
|
||||
func (p *Provider) ListTransfers(ctx context.Context, _ string) ([]*types.Transfer, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/user/transactions", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"action"` // deposit, withdrawal
|
||||
Amount float64 `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
Date string `json:"date_added"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
|
||||
transfers := make([]*types.Transfer, len(raw))
|
||||
for i, t := range raw {
|
||||
dir := "incoming"
|
||||
if t.Type == "withdrawal" {
|
||||
dir = "outgoing"
|
||||
}
|
||||
transfers[i] = &types.Transfer{
|
||||
Provider: "sfox",
|
||||
ProviderID: t.ID,
|
||||
Type: "crypto",
|
||||
Direction: dir,
|
||||
Amount: fmt.Sprintf("%.8f", t.Amount),
|
||||
Currency: t.Currency,
|
||||
Status: t.Status,
|
||||
}
|
||||
}
|
||||
return transfers, nil
|
||||
}
|
||||
|
||||
// --- Bank Relationships ---
|
||||
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) {
|
||||
return nil, fmt.Errorf("sfox: bank linking via SFOX dashboard")
|
||||
}
|
||||
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) {
|
||||
return []*types.BankRelationship{}, nil
|
||||
}
|
||||
|
||||
// --- Assets ---
|
||||
|
||||
func (p *Provider) ListAssets(ctx context.Context, _ string) ([]*types.Asset, error) {
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/markets/currency-pairs", nil)
|
||||
if err != nil {
|
||||
// Fallback to known SFOX pairs
|
||||
return p.defaultAssets(), nil
|
||||
}
|
||||
var pairs []struct {
|
||||
Pair string `json:"formatted_symbol"`
|
||||
BaseCurrency string `json:"base_currency"`
|
||||
QuoteCurrency string `json:"quote_currency"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &pairs); err != nil {
|
||||
return p.defaultAssets(), nil
|
||||
}
|
||||
assets := make([]*types.Asset, len(pairs))
|
||||
for i, pair := range pairs {
|
||||
assets[i] = &types.Asset{
|
||||
ID: pair.Pair,
|
||||
Provider: "sfox",
|
||||
Symbol: pair.BaseCurrency + "/" + pair.QuoteCurrency,
|
||||
Name: pair.BaseCurrency + "/" + pair.QuoteCurrency,
|
||||
Class: "crypto",
|
||||
Status: "active",
|
||||
Tradable: true,
|
||||
}
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func (p *Provider) defaultAssets() []*types.Asset {
|
||||
pairs := []string{
|
||||
"BTC", "ETH", "SOL", "AVAX", "DOT", "LINK", "UNI", "AAVE",
|
||||
"MATIC", "ARB", "OP", "LTC", "XRP", "ADA", "DOGE", "SHIB",
|
||||
"ATOM", "NEAR", "APT", "SUI", "TIA", "SEI", "INJ", "FTM",
|
||||
}
|
||||
assets := make([]*types.Asset, len(pairs))
|
||||
for i, s := range pairs {
|
||||
assets[i] = &types.Asset{
|
||||
ID: s + "USD", Provider: "sfox", Symbol: s + "/USD",
|
||||
Name: s + "/USD", Class: "crypto", Status: "active", Tradable: true,
|
||||
}
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(_ context.Context, symbolOrID string) (*types.Asset, error) {
|
||||
sym := strings.ToUpper(symbolOrID)
|
||||
return &types.Asset{
|
||||
ID: sym, Provider: "sfox", Symbol: sym,
|
||||
Class: "crypto", Status: "active", Tradable: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Market Data ---
|
||||
|
||||
func (p *Provider) GetSnapshot(ctx context.Context, symbol string) (*types.MarketSnapshot, error) {
|
||||
pair := normalizePair(symbol)
|
||||
data, _, err := p.do(ctx, http.MethodGet, "/markets/best-price/"+pair, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw struct {
|
||||
BuyPrice float64 `json:"buyPrice"`
|
||||
SellPrice float64 `json:"sellPrice"`
|
||||
High float64 `json:"high"`
|
||||
Low float64 `json:"low"`
|
||||
Open float64 `json:"open"`
|
||||
Last float64 `json:"last"`
|
||||
Volume float64 `json:"volume"`
|
||||
VWAP float64 `json:"vwap"`
|
||||
}
|
||||
json.Unmarshal(data, &raw)
|
||||
|
||||
return &types.MarketSnapshot{
|
||||
Symbol: symbol,
|
||||
LatestQuote: &types.Quote{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
BidPrice: raw.SellPrice, // SFOX sell = maker's bid
|
||||
AskPrice: raw.BuyPrice, // SFOX buy = maker's ask
|
||||
BidSize: 0,
|
||||
AskSize: 0,
|
||||
},
|
||||
LatestTrade: &types.Trade{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Price: raw.Last,
|
||||
Size: 0,
|
||||
},
|
||||
DailyBar: &types.Bar{
|
||||
Open: raw.Open,
|
||||
High: raw.High,
|
||||
Low: raw.Low,
|
||||
Close: raw.Last,
|
||||
Volume: raw.Volume,
|
||||
VWAP: raw.VWAP,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshots(ctx context.Context, symbols []string) (map[string]*types.MarketSnapshot, error) {
|
||||
result := make(map[string]*types.MarketSnapshot, len(symbols))
|
||||
for _, sym := range symbols {
|
||||
snap, err := p.GetSnapshot(ctx, sym)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result[sym] = snap
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetBars(_ context.Context, _, _, _, _ string, _ int) ([]*types.Bar, error) {
|
||||
return nil, fmt.Errorf("sfox: historical bars not yet implemented")
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestTrades(ctx context.Context, symbols []string) (map[string]*types.Trade, error) {
|
||||
result := make(map[string]*types.Trade, len(symbols))
|
||||
for _, sym := range symbols {
|
||||
snap, err := p.GetSnapshot(ctx, sym)
|
||||
if err != nil || snap.LatestTrade == nil {
|
||||
continue
|
||||
}
|
||||
result[sym] = snap.LatestTrade
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestQuotes(ctx context.Context, symbols []string) (map[string]*types.Quote, error) {
|
||||
result := make(map[string]*types.Quote, len(symbols))
|
||||
for _, sym := range symbols {
|
||||
snap, err := p.GetSnapshot(ctx, sym)
|
||||
if err != nil || snap.LatestQuote == nil {
|
||||
continue
|
||||
}
|
||||
result[sym] = snap.LatestQuote
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetClock(_ context.Context) (*types.MarketClock, error) {
|
||||
// Crypto markets are 24/7
|
||||
return &types.MarketClock{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
IsOpen: true,
|
||||
NextOpen: time.Now().UTC().Format(time.RFC3339),
|
||||
NextClose: time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetCalendar(_ context.Context, _, _ string) ([]*types.MarketCalendarDay, error) {
|
||||
return nil, fmt.Errorf("sfox: crypto markets are 24/7")
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// normalizePair converts "BTC/USD" or "BTCUSD" to "btcusd" for SFOX API.
|
||||
func normalizePair(symbol string) string {
|
||||
s := strings.ToLower(symbol)
|
||||
s = strings.ReplaceAll(s, "/", "")
|
||||
s = strings.ReplaceAll(s, "-", "")
|
||||
return s
|
||||
}
|
||||
|
||||
func mapOrderType(t string) string {
|
||||
switch t {
|
||||
case "market":
|
||||
return "market"
|
||||
case "limit":
|
||||
return "limit"
|
||||
default:
|
||||
return "market"
|
||||
}
|
||||
}
|
||||
|
||||
func mapStatus(s string) string {
|
||||
switch strings.ToLower(s) {
|
||||
case "started", "pending":
|
||||
return "new"
|
||||
case "cancel pending":
|
||||
return "pending_cancel"
|
||||
case "done", "filled":
|
||||
return "filled"
|
||||
case "canceled", "cancelled":
|
||||
return "cancelled"
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package tradier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ProdURL = "https://api.tradier.com"
|
||||
SandboxURL = "https://sandbox.tradier.com"
|
||||
)
|
||||
|
||||
type Config struct{ BaseURL, AccessToken, AccountID string }
|
||||
|
||||
type Provider struct{ cfg Config; client *http.Client }
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
if cfg.BaseURL == "" { cfg.BaseURL = ProdURL }
|
||||
return &Provider{cfg: cfg, client: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "tradier" }
|
||||
|
||||
var errNotSupported = fmt.Errorf("not supported by tradier")
|
||||
|
||||
func (p *Provider) doRequest(ctx context.Context, method, path string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, p.cfg.BaseURL+path, nil)
|
||||
if err != nil { return nil, err }
|
||||
req.Header.Set("Authorization", "Bearer "+p.cfg.AccessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil { return nil, err }
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 { return nil, fmt.Errorf("tradier error %d: %s", resp.StatusCode, string(data)) }
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CreateAccount(_ context.Context, _ *types.CreateAccountRequest) (*types.Account, error) { return nil, errNotSupported }
|
||||
|
||||
func (p *Provider) GetAccount(ctx context.Context, id string) (*types.Account, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/accounts/"+id)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Account struct{ AccountNumber string `json:"account_number"`; Status string `json:"status"` } `json:"account"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Account{ID: id, Provider: "tradier", ProviderID: id, AccountNumber: resp.Account.AccountNumber, Status: resp.Account.Status}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListAccounts(ctx context.Context) ([]*types.Account, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/user/profile")
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Profile struct{ Account []struct{ AccountNumber string `json:"account_number"` } `json:"account"` } `json:"profile"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
accts := make([]*types.Account, 0, len(resp.Profile.Account))
|
||||
for _, a := range resp.Profile.Account {
|
||||
accts = append(accts, &types.Account{ID: a.AccountNumber, Provider: "tradier", ProviderID: a.AccountNumber, AccountNumber: a.AccountNumber, Status: "active"})
|
||||
}
|
||||
return accts, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetPortfolio(ctx context.Context, id string) (*types.Portfolio, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/accounts/"+id+"/balances")
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Balances struct{ TotalEquity float64 `json:"total_equity"`; TotalCash float64 `json:"total_cash"`; MarketValue float64 `json:"market_value"` } `json:"balances"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Portfolio{AccountID: id, Cash: fmt.Sprintf("%.2f", resp.Balances.TotalCash), Equity: fmt.Sprintf("%.2f", resp.Balances.TotalEquity), PortfolioValue: fmt.Sprintf("%.2f", resp.Balances.MarketValue)}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CreateOrder(ctx context.Context, accountID string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
url := fmt.Sprintf("/v1/accounts/%s/orders?class=equity&symbol=%s&side=%s&quantity=%s&type=%s&duration=%s", accountID, req.Symbol, req.Side, req.Qty, req.Type, req.TimeInForce)
|
||||
data, err := p.doRequest(ctx, "POST", url)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Order struct{ ID int `json:"id"`; Status string `json:"status"` } `json:"order"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Order{ID: strconv.Itoa(resp.Order.ID), Provider: "tradier", ProviderID: strconv.Itoa(resp.Order.ID), AccountID: accountID, Symbol: req.Symbol, Qty: req.Qty, Side: req.Side, Type: req.Type, Status: resp.Order.Status, CreatedAt: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListOrders(ctx context.Context, accountID string) ([]*types.Order, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/accounts/"+accountID+"/orders")
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Orders struct{ Order []struct{ ID int `json:"id"`; Symbol string `json:"symbol"`; Side string `json:"side"`; Status string `json:"status"` } `json:"order"` } `json:"orders"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
orders := make([]*types.Order, 0, len(resp.Orders.Order))
|
||||
for _, o := range resp.Orders.Order { orders = append(orders, &types.Order{ID: strconv.Itoa(o.ID), Provider: "tradier", ProviderID: strconv.Itoa(o.ID), Symbol: o.Symbol, Side: o.Side, Status: o.Status}) }
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetOrder(ctx context.Context, accountID, orderID string) (*types.Order, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/accounts/"+accountID+"/orders/"+orderID)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Order struct{ ID int `json:"id"`; Status string `json:"status"` } `json:"order"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Order{ID: strconv.Itoa(resp.Order.ID), Provider: "tradier", ProviderID: strconv.Itoa(resp.Order.ID), Status: resp.Order.Status}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CancelOrder(ctx context.Context, accountID, orderID string) error {
|
||||
_, err := p.doRequest(ctx, "DELETE", "/v1/accounts/"+accountID+"/orders/"+orderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *Provider) CreateTransfer(_ context.Context, _ string, _ *types.CreateTransferRequest) (*types.Transfer, error) { return nil, errNotSupported }
|
||||
func (p *Provider) ListTransfers(_ context.Context, _ string) ([]*types.Transfer, error) { return nil, errNotSupported }
|
||||
func (p *Provider) CreateBankRelationship(_ context.Context, _, _, _, _, _ string) (*types.BankRelationship, error) { return nil, errNotSupported }
|
||||
func (p *Provider) ListBankRelationships(_ context.Context, _ string) ([]*types.BankRelationship, error) { return nil, errNotSupported }
|
||||
|
||||
func (p *Provider) ListAssets(ctx context.Context, class string) ([]*types.Asset, error) {
|
||||
return []*types.Asset{}, nil // Tradier requires search query, return empty for list
|
||||
}
|
||||
|
||||
func (p *Provider) GetAsset(ctx context.Context, sym string) (*types.Asset, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/markets/quotes?symbols="+sym)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Quotes struct{ Quote struct{ Symbol string `json:"symbol"`; Description string `json:"description"` } `json:"quote"` } `json:"quotes"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.Asset{ID: resp.Quotes.Quote.Symbol, Provider: "tradier", Symbol: resp.Quotes.Quote.Symbol, Name: resp.Quotes.Quote.Description, Class: "us_equity", Status: "active", Tradable: true}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshot(ctx context.Context, symbol string) (*types.MarketSnapshot, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/markets/quotes?symbols="+symbol)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Quotes struct{ Quote struct{ Bid float64 `json:"bid"`; Ask float64 `json:"ask"`; Last float64 `json:"last"`; Volume int `json:"volume"` } `json:"quote"` } `json:"quotes"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
q := resp.Quotes.Quote
|
||||
return &types.MarketSnapshot{Symbol: symbol, LatestQuote: &types.Quote{BidPrice: q.Bid, AskPrice: q.Ask}, LatestTrade: &types.Trade{Price: q.Last, Size: float64(q.Volume)}}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetSnapshots(ctx context.Context, symbols []string) (map[string]*types.MarketSnapshot, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/markets/quotes?symbols="+strings.Join(symbols, ","))
|
||||
if err != nil { return nil, err }
|
||||
result := make(map[string]*types.MarketSnapshot)
|
||||
var resp struct{ Quotes struct{ Quote json.RawMessage `json:"quote"` } `json:"quotes"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
// Handle single vs array
|
||||
var quotes []struct{ Symbol string `json:"symbol"`; Bid float64 `json:"bid"`; Ask float64 `json:"ask"`; Last float64 `json:"last"` }
|
||||
if err := json.Unmarshal(resp.Quotes.Quote, "es); err != nil {
|
||||
var single struct{ Symbol string `json:"symbol"`; Bid float64 `json:"bid"`; Ask float64 `json:"ask"`; Last float64 `json:"last"` }
|
||||
json.Unmarshal(resp.Quotes.Quote, &single)
|
||||
quotes = append(quotes, single)
|
||||
}
|
||||
for _, q := range quotes {
|
||||
result[q.Symbol] = &types.MarketSnapshot{Symbol: q.Symbol, LatestQuote: &types.Quote{BidPrice: q.Bid, AskPrice: q.Ask}, LatestTrade: &types.Trade{Price: q.Last}}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetBars(ctx context.Context, symbol, timeframe, start, end string, limit int) ([]*types.Bar, error) {
|
||||
url := fmt.Sprintf("/v1/markets/history?symbol=%s&interval=daily", symbol)
|
||||
if start != "" { url += "&start=" + start }
|
||||
if end != "" { url += "&end=" + end }
|
||||
data, err := p.doRequest(ctx, "GET", url)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ History struct{ Day []struct{ Date string `json:"date"`; Open float64 `json:"open"`; High float64 `json:"high"`; Low float64 `json:"low"`; Close float64 `json:"close"`; Volume float64 `json:"volume"` } `json:"day"` } `json:"history"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
bars := make([]*types.Bar, 0, len(resp.History.Day))
|
||||
for _, d := range resp.History.Day { bars = append(bars, &types.Bar{Timestamp: d.Date, Open: d.Open, High: d.High, Low: d.Low, Close: d.Close, Volume: d.Volume}) }
|
||||
return bars, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestTrades(ctx context.Context, symbols []string) (map[string]*types.Trade, error) {
|
||||
snaps, err := p.GetSnapshots(ctx, symbols)
|
||||
if err != nil { return nil, err }
|
||||
result := make(map[string]*types.Trade)
|
||||
for sym, snap := range snaps { if snap.LatestTrade != nil { result[sym] = snap.LatestTrade } }
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetLatestQuotes(ctx context.Context, symbols []string) (map[string]*types.Quote, error) {
|
||||
snaps, err := p.GetSnapshots(ctx, symbols)
|
||||
if err != nil { return nil, err }
|
||||
result := make(map[string]*types.Quote)
|
||||
for sym, snap := range snaps { if snap.LatestQuote != nil { result[sym] = snap.LatestQuote } }
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetClock(ctx context.Context) (*types.MarketClock, error) {
|
||||
data, err := p.doRequest(ctx, "GET", "/v1/markets/clock")
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Clock struct{ State string `json:"state"` } `json:"clock"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
return &types.MarketClock{IsOpen: resp.Clock.State == "open", Timestamp: time.Now().Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) GetCalendar(ctx context.Context, start, end string) ([]*types.MarketCalendarDay, error) {
|
||||
url := "/v1/markets/calendar"
|
||||
if start != "" { url += "?start=" + start }
|
||||
data, err := p.doRequest(ctx, "GET", url)
|
||||
if err != nil { return nil, err }
|
||||
var resp struct{ Calendar struct{ Days struct{ Day []struct{ Date string `json:"date"`; Open string `json:"open"`; Close string `json:"close"` } `json:"day"` } `json:"days"` } `json:"calendar"` }
|
||||
json.Unmarshal(data, &resp)
|
||||
days := make([]*types.MarketCalendarDay, 0, len(resp.Calendar.Days.Day))
|
||||
for _, d := range resp.Calendar.Days.Day { days = append(days, &types.MarketCalendarDay{Date: d.Date, Open: d.Open, Close: d.Close}) }
|
||||
return days, nil
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package risk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Engine performs pre-trade risk checks before orders reach providers.
|
||||
// Required for institutional compliance and risk management.
|
||||
type Engine struct {
|
||||
mu sync.RWMutex
|
||||
limits map[string]*AccountLimits // accountKey -> limits
|
||||
usage map[string]*AccountUsage // accountKey -> current usage
|
||||
global GlobalLimits
|
||||
}
|
||||
|
||||
// GlobalLimits are platform-wide risk limits.
|
||||
type GlobalLimits struct {
|
||||
MaxOrderValue float64 `json:"max_order_value"` // max single order in USD
|
||||
MaxDailyVolume float64 `json:"max_daily_volume"` // max daily volume per account in USD
|
||||
MaxOpenOrders int `json:"max_open_orders"` // max concurrent open orders per account
|
||||
MaxPositionValue float64 `json:"max_position_value"` // max position size in USD
|
||||
RateLimitPerMin int `json:"rate_limit_per_min"` // max orders per minute per account
|
||||
AllowedProviders []string `json:"allowed_providers"` // whitelist of providers
|
||||
BlockedSymbols []string `json:"blocked_symbols"` // symbols that cannot be traded
|
||||
CooldownAfterLoss time.Duration `json:"cooldown_after_loss"` // cooldown after significant loss
|
||||
}
|
||||
|
||||
// AccountLimits are per-account risk limits (override global).
|
||||
type AccountLimits struct {
|
||||
MaxOrderValue float64 `json:"max_order_value"`
|
||||
MaxDailyVolume float64 `json:"max_daily_volume"`
|
||||
MaxOpenOrders int `json:"max_open_orders"`
|
||||
MaxPositionValue float64 `json:"max_position_value"`
|
||||
RateLimitPerMin int `json:"rate_limit_per_min"`
|
||||
AllowedSymbols []string `json:"allowed_symbols,omitempty"` // if set, only these
|
||||
BlockedSymbols []string `json:"blocked_symbols,omitempty"`
|
||||
}
|
||||
|
||||
// AccountUsage tracks current resource usage for an account.
|
||||
type AccountUsage struct {
|
||||
DailyVolume float64
|
||||
OpenOrders int
|
||||
OrderTimestamps []time.Time // for rate limiting
|
||||
LastReset time.Time
|
||||
}
|
||||
|
||||
// CheckRequest contains the order details to validate.
|
||||
type CheckRequest struct {
|
||||
Provider string
|
||||
AccountID string
|
||||
Symbol string
|
||||
Side string
|
||||
Qty string
|
||||
Price string // estimated fill price
|
||||
OrderType string
|
||||
}
|
||||
|
||||
// CheckResult contains the risk check outcome.
|
||||
type CheckResult struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultLimits returns sensible defaults for a new platform.
|
||||
func DefaultLimits() GlobalLimits {
|
||||
return GlobalLimits{
|
||||
MaxOrderValue: 1_000_000, // $1M max single order
|
||||
MaxDailyVolume: 10_000_000, // $10M daily per account
|
||||
MaxOpenOrders: 100,
|
||||
MaxPositionValue: 5_000_000, // $5M max position
|
||||
RateLimitPerMin: 60, // 1 order/sec avg
|
||||
}
|
||||
}
|
||||
|
||||
func NewEngine(global GlobalLimits) *Engine {
|
||||
return &Engine{
|
||||
limits: make(map[string]*AccountLimits),
|
||||
usage: make(map[string]*AccountUsage),
|
||||
global: global,
|
||||
}
|
||||
}
|
||||
|
||||
// SetAccountLimits configures per-account limits.
|
||||
func (e *Engine) SetAccountLimits(provider, accountID string, limits AccountLimits) {
|
||||
key := provider + "/" + accountID
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.limits[key] = &limits
|
||||
}
|
||||
|
||||
// PreTradeCheck validates an order against all risk limits.
|
||||
func (e *Engine) PreTradeCheck(req CheckRequest) CheckResult {
|
||||
result := CheckResult{Allowed: true}
|
||||
key := req.Provider + "/" + req.AccountID
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
// Get or create usage tracker
|
||||
usage, ok := e.usage[key]
|
||||
if !ok {
|
||||
usage = &AccountUsage{LastReset: time.Now()}
|
||||
e.usage[key] = usage
|
||||
}
|
||||
|
||||
// Reset daily counters if needed
|
||||
if time.Since(usage.LastReset) > 24*time.Hour {
|
||||
usage.DailyVolume = 0
|
||||
usage.OrderTimestamps = nil
|
||||
usage.LastReset = time.Now()
|
||||
}
|
||||
|
||||
// Get effective limits (account overrides global)
|
||||
maxOrderVal := e.global.MaxOrderValue
|
||||
maxDailyVol := e.global.MaxDailyVolume
|
||||
maxOpen := e.global.MaxOpenOrders
|
||||
rateLimit := e.global.RateLimitPerMin
|
||||
|
||||
if acctLimits, ok := e.limits[key]; ok {
|
||||
if acctLimits.MaxOrderValue > 0 {
|
||||
maxOrderVal = acctLimits.MaxOrderValue
|
||||
}
|
||||
if acctLimits.MaxDailyVolume > 0 {
|
||||
maxDailyVol = acctLimits.MaxDailyVolume
|
||||
}
|
||||
if acctLimits.MaxOpenOrders > 0 {
|
||||
maxOpen = acctLimits.MaxOpenOrders
|
||||
}
|
||||
if acctLimits.RateLimitPerMin > 0 {
|
||||
rateLimit = acctLimits.RateLimitPerMin
|
||||
}
|
||||
|
||||
// Check allowed/blocked symbols
|
||||
if len(acctLimits.AllowedSymbols) > 0 {
|
||||
found := false
|
||||
for _, s := range acctLimits.AllowedSymbols {
|
||||
if s == req.Symbol {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
result.Allowed = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("symbol %s not in allowed list", req.Symbol))
|
||||
}
|
||||
}
|
||||
for _, s := range acctLimits.BlockedSymbols {
|
||||
if s == req.Symbol {
|
||||
result.Allowed = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("symbol %s is blocked", req.Symbol))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check global blocked symbols
|
||||
for _, s := range e.global.BlockedSymbols {
|
||||
if s == req.Symbol {
|
||||
result.Allowed = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("symbol %s is globally blocked", req.Symbol))
|
||||
}
|
||||
}
|
||||
|
||||
// Check provider allowlist
|
||||
if len(e.global.AllowedProviders) > 0 {
|
||||
found := false
|
||||
for _, p := range e.global.AllowedProviders {
|
||||
if p == req.Provider {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
result.Allowed = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("provider %s not allowed", req.Provider))
|
||||
}
|
||||
}
|
||||
|
||||
// Estimate order value
|
||||
orderValue := estimateOrderValue(req.Qty, req.Price)
|
||||
|
||||
// Max order value check
|
||||
if orderValue > maxOrderVal {
|
||||
result.Allowed = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("order value $%.2f exceeds limit $%.2f", orderValue, maxOrderVal))
|
||||
}
|
||||
|
||||
// Daily volume check
|
||||
if usage.DailyVolume+orderValue > maxDailyVol {
|
||||
result.Allowed = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("daily volume would exceed limit $%.2f", maxDailyVol))
|
||||
}
|
||||
|
||||
// Open orders check
|
||||
if usage.OpenOrders >= maxOpen {
|
||||
result.Allowed = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("open orders %d at limit %d", usage.OpenOrders, maxOpen))
|
||||
}
|
||||
|
||||
// Rate limit check
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-time.Minute)
|
||||
recent := 0
|
||||
for _, ts := range usage.OrderTimestamps {
|
||||
if ts.After(cutoff) {
|
||||
recent++
|
||||
}
|
||||
}
|
||||
if recent >= rateLimit {
|
||||
result.Allowed = false
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("rate limit: %d orders in last minute (limit %d)", recent, rateLimit))
|
||||
}
|
||||
|
||||
// Warnings for large orders
|
||||
if orderValue > maxOrderVal*0.5 {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("large order: $%.2f (%.0f%% of limit)", orderValue, orderValue/maxOrderVal*100))
|
||||
}
|
||||
if usage.DailyVolume+orderValue > maxDailyVol*0.8 {
|
||||
result.Warnings = append(result.Warnings, "approaching daily volume limit")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// RecordOrder updates usage after an order is placed.
|
||||
func (e *Engine) RecordOrder(provider, accountID string, orderValue float64) {
|
||||
key := provider + "/" + accountID
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
usage, ok := e.usage[key]
|
||||
if !ok {
|
||||
usage = &AccountUsage{LastReset: time.Now()}
|
||||
e.usage[key] = usage
|
||||
}
|
||||
usage.DailyVolume += orderValue
|
||||
usage.OpenOrders++
|
||||
usage.OrderTimestamps = append(usage.OrderTimestamps, time.Now())
|
||||
}
|
||||
|
||||
// RecordFill updates usage when an order fills or is cancelled.
|
||||
func (e *Engine) RecordFill(provider, accountID string) {
|
||||
key := provider + "/" + accountID
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if usage, ok := e.usage[key]; ok {
|
||||
if usage.OpenOrders > 0 {
|
||||
usage.OpenOrders--
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func estimateOrderValue(qty, price string) float64 {
|
||||
q, _ := strconv.ParseFloat(qty, 64)
|
||||
p, _ := strconv.ParseFloat(price, 64)
|
||||
if p == 0 {
|
||||
p = 1 // market orders without price estimate
|
||||
}
|
||||
return math.Abs(q * p)
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/provider"
|
||||
"github.com/luxfi/broker/pkg/types"
|
||||
)
|
||||
|
||||
// Router provides institutional-grade smart order routing across multiple providers.
|
||||
// Features: best-execution routing, order splitting, fee-aware net-price routing,
|
||||
// TWAP scheduling, provider capability tracking.
|
||||
type Router struct {
|
||||
registry *provider.Registry
|
||||
fees map[string]*types.ProviderFees // provider -> fee schedule
|
||||
capabilities map[string]*types.ProviderCapability
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func New(registry *provider.Registry) *Router {
|
||||
return &Router{
|
||||
registry: registry,
|
||||
fees: defaultFees(),
|
||||
capabilities: defaultCapabilities(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetFees configures fee schedule for a provider (used in net-price routing).
|
||||
func (r *Router) SetFees(provider string, makerBps, takerBps float64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.fees[provider] = &types.ProviderFees{Provider: provider, MakerBps: makerBps, TakerBps: takerBps}
|
||||
}
|
||||
|
||||
// SetCapability registers a provider's capabilities.
|
||||
func (r *Router) SetCapability(cap *types.ProviderCapability) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.capabilities[cap.Name] = cap
|
||||
}
|
||||
|
||||
// GetCapabilities returns all registered provider capabilities.
|
||||
func (r *Router) GetCapabilities() []*types.ProviderCapability {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
caps := make([]*types.ProviderCapability, 0, len(r.capabilities))
|
||||
for _, c := range r.capabilities {
|
||||
cp := *c
|
||||
if fees, ok := r.fees[cp.Name]; ok {
|
||||
cp.MakerFee = fees.MakerBps
|
||||
cp.TakerFee = fees.TakerBps
|
||||
}
|
||||
caps = append(caps, &cp)
|
||||
}
|
||||
sort.Slice(caps, func(i, j int) bool { return caps[i].Name < caps[j].Name })
|
||||
return caps
|
||||
}
|
||||
|
||||
// RouteResult contains routing decision details.
|
||||
type RouteResult struct {
|
||||
Provider string `json:"provider"`
|
||||
Symbol string `json:"symbol"`
|
||||
BidPrice float64 `json:"bid_price,omitempty"`
|
||||
AskPrice float64 `json:"ask_price,omitempty"`
|
||||
Spread float64 `json:"spread,omitempty"`
|
||||
SpreadBps float64 `json:"spread_bps,omitempty"`
|
||||
MakerFeeBps float64 `json:"maker_fee_bps,omitempty"`
|
||||
TakerFeeBps float64 `json:"taker_fee_bps,omitempty"`
|
||||
NetPrice float64 `json:"net_price,omitempty"` // price + fees
|
||||
Score float64 `json:"score"` // lower = better
|
||||
}
|
||||
|
||||
// FindBestProvider returns the best provider for trading a given symbol.
|
||||
func (r *Router) FindBestProvider(ctx context.Context, symbol, side string) (*RouteResult, error) {
|
||||
routes, err := r.GetAllRoutes(ctx, symbol, side)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(routes) == 0 {
|
||||
return nil, fmt.Errorf("no provider supports symbol %s", symbol)
|
||||
}
|
||||
return routes[0], nil
|
||||
}
|
||||
|
||||
// GetAllRoutes returns all available routes for a symbol, ranked by net execution price.
|
||||
func (r *Router) GetAllRoutes(ctx context.Context, symbol, side string) ([]*RouteResult, error) {
|
||||
providerNames := r.registry.List()
|
||||
if len(providerNames) == 0 {
|
||||
return nil, fmt.Errorf("no providers registered")
|
||||
}
|
||||
|
||||
type result struct {
|
||||
route *RouteResult
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
asset, err := p.GetAsset(ctx, symbol)
|
||||
if err != nil || !asset.Tradable {
|
||||
return
|
||||
}
|
||||
|
||||
route := &RouteResult{
|
||||
Provider: provName,
|
||||
Symbol: symbol,
|
||||
Score: 1000,
|
||||
}
|
||||
|
||||
// Get fee schedule
|
||||
r.mu.RLock()
|
||||
fees := r.fees[provName]
|
||||
r.mu.RUnlock()
|
||||
if fees != nil {
|
||||
route.MakerFeeBps = fees.MakerBps
|
||||
route.TakerFeeBps = fees.TakerBps
|
||||
}
|
||||
|
||||
snap, err := p.GetSnapshot(ctx, symbol)
|
||||
if err == nil && snap.LatestQuote != nil {
|
||||
q := snap.LatestQuote
|
||||
route.BidPrice = q.BidPrice
|
||||
route.AskPrice = q.AskPrice
|
||||
if q.AskPrice > 0 && q.BidPrice > 0 {
|
||||
route.Spread = q.AskPrice - q.BidPrice
|
||||
mid := (q.AskPrice + q.BidPrice) / 2
|
||||
if mid > 0 {
|
||||
route.SpreadBps = (route.Spread / mid) * 10000
|
||||
}
|
||||
|
||||
// Net-price routing: factor in taker fees
|
||||
feeBps := route.TakerFeeBps
|
||||
if strings.EqualFold(side, "buy") {
|
||||
route.NetPrice = q.AskPrice * (1 + feeBps/10000)
|
||||
route.Score = route.NetPrice
|
||||
} else {
|
||||
route.NetPrice = q.BidPrice * (1 - feeBps/10000)
|
||||
route.Score = -route.NetPrice // negate: higher net = better for sells
|
||||
}
|
||||
}
|
||||
} else if err == nil && snap.LatestTrade != nil {
|
||||
route.Score = 500
|
||||
} else {
|
||||
route.Score = 999
|
||||
}
|
||||
|
||||
ch <- result{route: route}
|
||||
}(name)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
var routes []*RouteResult
|
||||
for res := range ch {
|
||||
if res.route != nil {
|
||||
routes = append(routes, res.route)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
return routes[i].Score < routes[j].Score
|
||||
})
|
||||
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
// BuildSplitPlan creates an execution plan that splits an order across providers.
|
||||
// This minimizes market impact for large orders and captures best price across venues.
|
||||
func (r *Router) BuildSplitPlan(ctx context.Context, symbol, side, qty string) (*types.SplitPlan, error) {
|
||||
routes, err := r.GetAllRoutes(ctx, symbol, side)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(routes) == 0 {
|
||||
return nil, fmt.Errorf("no providers support %s", symbol)
|
||||
}
|
||||
|
||||
totalQty, _ := strconv.ParseFloat(qty, 64)
|
||||
if totalQty <= 0 {
|
||||
return nil, fmt.Errorf("invalid quantity")
|
||||
}
|
||||
|
||||
plan := &types.SplitPlan{
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
TotalQty: qty,
|
||||
Algorithm: "split",
|
||||
}
|
||||
|
||||
// For routes with quotes, distribute proportionally to spread quality
|
||||
quotedRoutes := make([]*RouteResult, 0)
|
||||
for _, route := range routes {
|
||||
if route.BidPrice > 0 || route.AskPrice > 0 {
|
||||
quotedRoutes = append(quotedRoutes, route)
|
||||
}
|
||||
}
|
||||
|
||||
if len(quotedRoutes) == 0 {
|
||||
// No quotes — send all to first available
|
||||
plan.Legs = []types.SplitLeg{{
|
||||
Provider: routes[0].Provider,
|
||||
Qty: qty,
|
||||
EstimatedPrice: 0,
|
||||
}}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// Score-weighted split: better scores get more volume
|
||||
// Invert scores so lower score = higher weight
|
||||
var totalWeight float64
|
||||
weights := make([]float64, len(quotedRoutes))
|
||||
for i, route := range quotedRoutes {
|
||||
if route.Score > 0 {
|
||||
weights[i] = 1.0 / route.Score
|
||||
} else {
|
||||
weights[i] = 1.0 / math.Abs(route.Score)
|
||||
}
|
||||
totalWeight += weights[i]
|
||||
}
|
||||
|
||||
var estimatedVWAP, estimatedFees, totalAllocated float64
|
||||
for i, route := range quotedRoutes {
|
||||
proportion := weights[i] / totalWeight
|
||||
legQty := totalQty * proportion
|
||||
|
||||
// Last leg gets remainder to avoid rounding issues
|
||||
if i == len(quotedRoutes)-1 {
|
||||
legQty = totalQty - totalAllocated
|
||||
}
|
||||
totalAllocated += legQty
|
||||
|
||||
var price float64
|
||||
if strings.EqualFold(side, "buy") {
|
||||
price = route.AskPrice
|
||||
} else {
|
||||
price = route.BidPrice
|
||||
}
|
||||
|
||||
feePct := route.TakerFeeBps / 10000
|
||||
legFee := legQty * price * feePct
|
||||
|
||||
plan.Legs = append(plan.Legs, types.SplitLeg{
|
||||
Provider: route.Provider,
|
||||
Qty: fmt.Sprintf("%.8f", legQty),
|
||||
EstimatedPrice: price,
|
||||
EstimatedFee: legFee,
|
||||
BidPrice: route.BidPrice,
|
||||
AskPrice: route.AskPrice,
|
||||
})
|
||||
|
||||
estimatedVWAP += price * legQty
|
||||
estimatedFees += legFee
|
||||
}
|
||||
|
||||
if totalQty > 0 {
|
||||
plan.EstimatedVWAP = estimatedVWAP / totalQty
|
||||
}
|
||||
plan.EstimatedFees = estimatedFees
|
||||
plan.EstimatedNet = plan.EstimatedVWAP + (estimatedFees / totalQty)
|
||||
|
||||
// Calculate savings vs single venue (worst route)
|
||||
if len(quotedRoutes) > 1 {
|
||||
worstPrice := quotedRoutes[len(quotedRoutes)-1].NetPrice
|
||||
if worstPrice != 0 && plan.EstimatedNet != 0 {
|
||||
plan.Savings = math.Abs((worstPrice-plan.EstimatedNet)/plan.EstimatedNet) * 10000
|
||||
}
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// ExecuteSplitPlan executes all legs of a split plan in parallel.
|
||||
func (r *Router) ExecuteSplitPlan(ctx context.Context, plan *types.SplitPlan, accounts map[string]string) (*types.ExecutionResult, error) {
|
||||
start := time.Now()
|
||||
result := &types.ExecutionResult{
|
||||
PlanID: fmt.Sprintf("exec_%d", start.UnixNano()),
|
||||
Symbol: plan.Symbol,
|
||||
Side: plan.Side,
|
||||
Algorithm: plan.Algorithm,
|
||||
TotalQty: plan.TotalQty,
|
||||
StartedAt: start,
|
||||
}
|
||||
|
||||
type legResult struct {
|
||||
leg types.ExecutionLeg
|
||||
err error
|
||||
}
|
||||
|
||||
ch := make(chan legResult, len(plan.Legs))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, leg := range plan.Legs {
|
||||
wg.Add(1)
|
||||
go func(l types.SplitLeg) {
|
||||
defer wg.Done()
|
||||
legStart := time.Now()
|
||||
|
||||
accountID, ok := accounts[l.Provider]
|
||||
if !ok {
|
||||
ch <- legResult{err: fmt.Errorf("no account for provider %s", l.Provider)}
|
||||
return
|
||||
}
|
||||
|
||||
p, err := r.registry.Get(l.Provider)
|
||||
if err != nil {
|
||||
ch <- legResult{err: err}
|
||||
return
|
||||
}
|
||||
|
||||
order, err := p.CreateOrder(ctx, accountID, &types.CreateOrderRequest{
|
||||
Symbol: plan.Symbol,
|
||||
Qty: l.Qty,
|
||||
Side: plan.Side,
|
||||
Type: "market",
|
||||
TimeInForce: "ioc",
|
||||
})
|
||||
|
||||
el := types.ExecutionLeg{
|
||||
Provider: l.Provider,
|
||||
Qty: l.Qty,
|
||||
Latency: time.Since(legStart).String(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
el.Status = "failed"
|
||||
ch <- legResult{leg: el, err: err}
|
||||
return
|
||||
}
|
||||
|
||||
el.OrderID = order.ProviderID
|
||||
el.FilledQty = order.FilledQty
|
||||
el.Status = order.Status
|
||||
if order.FilledAvgPrice != "" {
|
||||
el.Price, _ = strconv.ParseFloat(order.FilledAvgPrice, 64)
|
||||
}
|
||||
ch <- legResult{leg: el}
|
||||
}(leg)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
var totalFilled, weightedPrice float64
|
||||
allFilled := true
|
||||
for lr := range ch {
|
||||
result.Legs = append(result.Legs, lr.leg)
|
||||
filled, _ := strconv.ParseFloat(lr.leg.FilledQty, 64)
|
||||
totalFilled += filled
|
||||
weightedPrice += lr.leg.Price * filled
|
||||
if lr.leg.Status != "filled" {
|
||||
allFilled = false
|
||||
}
|
||||
}
|
||||
|
||||
result.FilledQty = fmt.Sprintf("%.8f", totalFilled)
|
||||
if totalFilled > 0 {
|
||||
result.VWAP = weightedPrice / totalFilled
|
||||
}
|
||||
result.CompletedAt = time.Now()
|
||||
result.Latency = time.Since(start).String()
|
||||
|
||||
if allFilled {
|
||||
result.Status = "filled"
|
||||
} else if totalFilled > 0 {
|
||||
result.Status = "partial"
|
||||
} else {
|
||||
result.Status = "failed"
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SmartOrder places an order using the best available provider.
|
||||
func (r *Router) SmartOrder(ctx context.Context, accountsByProvider map[string]string, req *types.CreateOrderRequest) (*types.Order, error) {
|
||||
best, err := r.FindBestProvider(ctx, req.Symbol, req.Side)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("routing failed: %w", err)
|
||||
}
|
||||
|
||||
accountID, ok := accountsByProvider[best.Provider]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no account configured for provider %s", best.Provider)
|
||||
}
|
||||
|
||||
p, err := r.registry.Get(best.Provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
order, err := p.CreateOrder(ctx, accountID, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("order via %s failed: %w", best.Provider, err)
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// AggregatedAssets returns all tradable assets across all providers, deduplicated.
|
||||
func (r *Router) AggregatedAssets(ctx context.Context) ([]*AggregatedAsset, error) {
|
||||
providerNames := r.registry.List()
|
||||
assetMap := make(map[string]*AggregatedAsset)
|
||||
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, name := range providerNames {
|
||||
wg.Add(1)
|
||||
go func(provName string) {
|
||||
defer wg.Done()
|
||||
p, _ := r.registry.Get(provName)
|
||||
assets, err := p.ListAssets(ctx, "")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, a := range assets {
|
||||
if !a.Tradable {
|
||||
continue
|
||||
}
|
||||
sym := strings.ToUpper(a.Symbol)
|
||||
if existing, ok := assetMap[sym]; ok {
|
||||
existing.Providers = append(existing.Providers, provName)
|
||||
} else {
|
||||
assetMap[sym] = &AggregatedAsset{
|
||||
Symbol: sym,
|
||||
Name: a.Name,
|
||||
Class: a.Class,
|
||||
Providers: []string{provName},
|
||||
}
|
||||
}
|
||||
}
|
||||
}(name)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
result := make([]*AggregatedAsset, 0, len(assetMap))
|
||||
for _, a := range assetMap {
|
||||
sort.Strings(a.Providers)
|
||||
result = append(result, a)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Symbol < result[j].Symbol
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AggregatedAsset is an asset available across multiple providers.
|
||||
type AggregatedAsset struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
Class string `json:"class"`
|
||||
Providers []string `json:"providers"`
|
||||
}
|
||||
|
||||
// --- Default configurations ---
|
||||
|
||||
func defaultFees() map[string]*types.ProviderFees {
|
||||
return map[string]*types.ProviderFees{
|
||||
"alpaca": {Provider: "alpaca", MakerBps: 0, TakerBps: 0}, // commission-free equities
|
||||
"sfox": {Provider: "sfox", MakerBps: 15, TakerBps: 25}, // 15/25 bps
|
||||
"coinbase": {Provider: "coinbase", MakerBps: 40, TakerBps: 60}, // 40/60 bps
|
||||
"kraken": {Provider: "kraken", MakerBps: 16, TakerBps: 26}, // 16/26 bps
|
||||
"binance": {Provider: "binance", MakerBps: 10, TakerBps: 10}, // 10/10 bps
|
||||
"gemini": {Provider: "gemini", MakerBps: 20, TakerBps: 40}, // 20/40 bps
|
||||
"bitgo": {Provider: "bitgo", MakerBps: 25, TakerBps: 25}, // estimate
|
||||
"falcon": {Provider: "falcon", MakerBps: 5, TakerBps: 10}, // institutional
|
||||
"ibkr": {Provider: "ibkr", MakerBps: 0.5, TakerBps: 0.5}, // per-share, approximated
|
||||
"finix": {Provider: "finix", MakerBps: 0, TakerBps: 0}, // payment processor
|
||||
"fireblocks": {Provider: "fireblocks", MakerBps: 0, TakerBps: 0}, // custody, no trading fees
|
||||
"circle": {Provider: "circle", MakerBps: 0, TakerBps: 0}, // stablecoin transfers
|
||||
"tradier": {Provider: "tradier", MakerBps: 0, TakerBps: 0}, // commission-free equities
|
||||
"polygon": {Provider: "polygon", MakerBps: 0, TakerBps: 0}, // market data only
|
||||
"currencycloud": {Provider: "currencycloud", MakerBps: 3, TakerBps: 5}, // tight FX spreads
|
||||
"lmax": {Provider: "lmax", MakerBps: 2, TakerBps: 3}, // institutional CLOB
|
||||
}
|
||||
}
|
||||
|
||||
func defaultCapabilities() map[string]*types.ProviderCapability {
|
||||
return map[string]*types.ProviderCapability{
|
||||
"alpaca": {
|
||||
Name: "alpaca", Status: "active",
|
||||
AssetClasses: []string{"us_equity", "crypto"},
|
||||
OrderTypes: []string{"market", "limit", "stop", "stop_limit", "trailing_stop"},
|
||||
Features: []string{"ach", "wire", "fractional", "extended_hours", "margin"},
|
||||
},
|
||||
"sfox": {
|
||||
Name: "sfox", Status: "active",
|
||||
AssetClasses: []string{"crypto"},
|
||||
OrderTypes: []string{"market", "limit", "stop", "smart_route", "twap", "sniper", "hare", "tortoise", "polar_bear", "gorilla"},
|
||||
Features: []string{"smart_routing", "dark_pool", "custody", "150+_pairs"},
|
||||
},
|
||||
"coinbase": {
|
||||
Name: "coinbase", Status: "active",
|
||||
AssetClasses: []string{"crypto"},
|
||||
OrderTypes: []string{"market", "limit", "stop"},
|
||||
Features: []string{"staking", "custody", "250+_pairs"},
|
||||
},
|
||||
"kraken": {
|
||||
Name: "kraken", Status: "active",
|
||||
AssetClasses: []string{"crypto"},
|
||||
OrderTypes: []string{"market", "limit", "stop", "stop_limit", "trailing_stop"},
|
||||
Features: []string{"staking", "margin", "futures", "200+_pairs"},
|
||||
},
|
||||
"binance": {
|
||||
Name: "binance", Status: "active",
|
||||
AssetClasses: []string{"crypto"},
|
||||
OrderTypes: []string{"market", "limit", "stop", "oco"},
|
||||
Features: []string{"margin", "futures", "staking", "600+_pairs"},
|
||||
},
|
||||
"gemini": {
|
||||
Name: "gemini", Status: "active",
|
||||
AssetClasses: []string{"crypto"},
|
||||
OrderTypes: []string{"market", "limit", "stop"},
|
||||
Features: []string{"custody", "staking", "100+_pairs"},
|
||||
},
|
||||
"ibkr": {
|
||||
Name: "ibkr", Status: "active",
|
||||
AssetClasses: []string{"us_equity", "option", "futures", "forex", "bond", "crypto"},
|
||||
OrderTypes: []string{"market", "limit", "stop", "stop_limit", "trailing_stop", "algo"},
|
||||
Features: []string{"margin", "short_selling", "options", "futures", "global_markets"},
|
||||
},
|
||||
"bitgo": {
|
||||
Name: "bitgo", Status: "active",
|
||||
AssetClasses: []string{"crypto"},
|
||||
OrderTypes: []string{"market", "limit"},
|
||||
Features: []string{"custody", "multisig", "staking", "prime_trading"},
|
||||
},
|
||||
"falcon": {
|
||||
Name: "falcon", Status: "active",
|
||||
AssetClasses: []string{"crypto"},
|
||||
OrderTypes: []string{"rfq"},
|
||||
Features: []string{"institutional", "otc", "settlement"},
|
||||
},
|
||||
"finix": {
|
||||
Name: "finix", Status: "active",
|
||||
AssetClasses: []string{"payments"},
|
||||
OrderTypes: []string{},
|
||||
Features: []string{"ach", "wire", "card", "apple_pay", "google_pay"},
|
||||
},
|
||||
"fireblocks": {
|
||||
Name: "fireblocks", Status: "active",
|
||||
AssetClasses: []string{"crypto"},
|
||||
OrderTypes: []string{"transfer"},
|
||||
Features: []string{"custody", "multisig", "mpc", "defi", "staking", "nft", "500+_assets"},
|
||||
},
|
||||
"circle": {
|
||||
Name: "circle", Status: "active",
|
||||
AssetClasses: []string{"stablecoin"},
|
||||
OrderTypes: []string{"transfer"},
|
||||
Features: []string{"usdc", "eurc", "mint", "burn", "cross_chain"},
|
||||
},
|
||||
"tradier": {
|
||||
Name: "tradier", Status: "active",
|
||||
AssetClasses: []string{"us_equity", "option"},
|
||||
OrderTypes: []string{"market", "limit", "stop", "stop_limit"},
|
||||
Features: []string{"commission_free", "options", "streaming", "extended_hours"},
|
||||
},
|
||||
"polygon": {
|
||||
Name: "polygon", Status: "active",
|
||||
AssetClasses: []string{"us_equity", "crypto", "forex", "option"},
|
||||
OrderTypes: []string{},
|
||||
Features: []string{"market_data", "snapshots", "bars", "trades", "quotes", "real_time"},
|
||||
},
|
||||
"currencycloud": {
|
||||
Name: "currencycloud", Status: "active",
|
||||
AssetClasses: []string{"forex"},
|
||||
OrderTypes: []string{"spot", "forward"},
|
||||
Features: []string{"fx_trading", "cross_border", "35+_currencies", "competitive_spreads", "payments"},
|
||||
},
|
||||
"lmax": {
|
||||
Name: "lmax", Status: "active",
|
||||
AssetClasses: []string{"forex", "crypto", "commodity"},
|
||||
OrderTypes: []string{"market", "limit", "stop"},
|
||||
Features: []string{"institutional", "clob", "fca_regulated", "sub_ms_matching", "precious_metals"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// Account is the unified brokerage account across all providers.
|
||||
type Account struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"` // alpaca, ibkr, coinbase, etc.
|
||||
ProviderID string `json:"provider_id"` // provider's account ID
|
||||
OrgID string `json:"org_id"` // lux org tenant
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
AccountNumber string `json:"account_number"`
|
||||
Status string `json:"status"`
|
||||
Currency string `json:"currency"`
|
||||
AccountType string `json:"account_type,omitempty"`
|
||||
EnabledAssets []string `json:"enabled_assets,omitempty"`
|
||||
Identity *Identity `json:"identity,omitempty"`
|
||||
Contact *Contact `json:"contact,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Identity struct {
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
DateOfBirth string `json:"date_of_birth"`
|
||||
TaxID string `json:"tax_id,omitempty"`
|
||||
TaxIDType string `json:"tax_id_type,omitempty"`
|
||||
CountryOfTax string `json:"country_of_tax_residence,omitempty"`
|
||||
}
|
||||
|
||||
type Contact struct {
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Street []string `json:"street,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
PostalCode string `json:"postal_code,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
}
|
||||
|
||||
// Portfolio is a snapshot of an account's holdings.
|
||||
type Portfolio struct {
|
||||
AccountID string `json:"account_id"`
|
||||
Cash string `json:"cash"`
|
||||
Equity string `json:"equity"`
|
||||
BuyingPower string `json:"buying_power"`
|
||||
PortfolioValue string `json:"portfolio_value"`
|
||||
Positions []Position `json:"positions"`
|
||||
}
|
||||
|
||||
// Position is a single holding.
|
||||
type Position 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"`
|
||||
}
|
||||
|
||||
// Order is a unified trade order.
|
||||
type Order struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Qty string `json:"qty,omitempty"`
|
||||
Notional string `json:"notional,omitempty"`
|
||||
Side string `json:"side"` // buy, sell
|
||||
Type string `json:"type"` // market, limit, stop, stop_limit
|
||||
TimeInForce string `json:"time_in_force"`
|
||||
LimitPrice string `json:"limit_price,omitempty"`
|
||||
StopPrice string `json:"stop_price,omitempty"`
|
||||
Status string `json:"status"`
|
||||
FilledQty string `json:"filled_qty,omitempty"`
|
||||
FilledAvgPrice string `json:"filled_avg_price,omitempty"`
|
||||
AssetClass string `json:"asset_class,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
FilledAt *time.Time `json:"filled_at,omitempty"`
|
||||
}
|
||||
|
||||
// CreateOrderRequest is the unified order creation request.
|
||||
type CreateOrderRequest struct {
|
||||
AccountID string `json:"account_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Qty string `json:"qty,omitempty"`
|
||||
Notional string `json:"notional,omitempty"`
|
||||
Side string `json:"side"`
|
||||
Type string `json:"type"`
|
||||
TimeInForce string `json:"time_in_force"`
|
||||
LimitPrice string `json:"limit_price,omitempty"`
|
||||
StopPrice string `json:"stop_price,omitempty"`
|
||||
}
|
||||
|
||||
// Asset is a tradable instrument.
|
||||
type Asset struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
Class string `json:"class"` // us_equity, crypto
|
||||
Exchange string `json:"exchange,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Tradable bool `json:"tradable"`
|
||||
Fractionable bool `json:"fractionable"`
|
||||
}
|
||||
|
||||
// Transfer is a fund movement (ACH, wire, crypto).
|
||||
type Transfer struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
Type string `json:"type"` // ach, wire, crypto
|
||||
Direction string `json:"direction"` // incoming, outgoing
|
||||
Amount string `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateTransferRequest for funding/withdrawals.
|
||||
type CreateTransferRequest struct {
|
||||
AccountID string `json:"account_id"`
|
||||
Type string `json:"type"`
|
||||
Direction string `json:"direction"`
|
||||
Amount string `json:"amount"`
|
||||
RelationshipID string `json:"relationship_id,omitempty"`
|
||||
}
|
||||
|
||||
// BankRelationship links an external bank account.
|
||||
type BankRelationship struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
BankName string `json:"bank_name,omitempty"`
|
||||
AccountOwnerName string `json:"account_owner_name"`
|
||||
BankAccountType string `json:"bank_account_type"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// MarketSnapshot is a real-time price snapshot for a symbol.
|
||||
type MarketSnapshot struct {
|
||||
Symbol string `json:"symbol"`
|
||||
LatestTrade *Trade `json:"latest_trade,omitempty"`
|
||||
LatestQuote *Quote `json:"latest_quote,omitempty"`
|
||||
MinuteBar *Bar `json:"minute_bar,omitempty"`
|
||||
DailyBar *Bar `json:"daily_bar,omitempty"`
|
||||
PrevDailyBar *Bar `json:"prev_daily_bar,omitempty"`
|
||||
}
|
||||
|
||||
// Trade is a single executed trade.
|
||||
type Trade struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Price float64 `json:"price"`
|
||||
Size float64 `json:"size"`
|
||||
Exchange string `json:"exchange,omitempty"`
|
||||
}
|
||||
|
||||
// Quote is a bid/ask quote.
|
||||
type Quote struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
BidPrice float64 `json:"bid_price"`
|
||||
BidSize float64 `json:"bid_size"`
|
||||
AskPrice float64 `json:"ask_price"`
|
||||
AskSize float64 `json:"ask_size"`
|
||||
}
|
||||
|
||||
// Bar is an OHLCV candle.
|
||||
type Bar struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Open float64 `json:"open"`
|
||||
High float64 `json:"high"`
|
||||
Low float64 `json:"low"`
|
||||
Close float64 `json:"close"`
|
||||
Volume float64 `json:"volume"`
|
||||
VWAP float64 `json:"vwap,omitempty"`
|
||||
TradeCount int `json:"trade_count,omitempty"`
|
||||
}
|
||||
|
||||
// MarketClock represents the trading clock.
|
||||
type MarketClock struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
IsOpen bool `json:"is_open"`
|
||||
NextOpen string `json:"next_open"`
|
||||
NextClose string `json:"next_close"`
|
||||
}
|
||||
|
||||
// MarketCalendarDay is a single trading day's schedule.
|
||||
type MarketCalendarDay struct {
|
||||
Date string `json:"date"`
|
||||
Open string `json:"open"`
|
||||
Close string `json:"close"`
|
||||
}
|
||||
|
||||
// CreateAccountRequest for onboarding.
|
||||
type CreateAccountRequest struct {
|
||||
Provider string `json:"provider"` // which broker to use
|
||||
OrgID string `json:"-"` // set from auth context
|
||||
UserID string `json:"-"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Contact *Contact `json:"contact"`
|
||||
EnabledAssets []string `json:"enabled_assets,omitempty"`
|
||||
}
|
||||
|
||||
// --- Smart Order Routing Types ---
|
||||
|
||||
// SmartOrderRequest is a cross-provider order with algorithm selection.
|
||||
type SmartOrderRequest struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Qty string `json:"qty,omitempty"`
|
||||
Notional string `json:"notional,omitempty"`
|
||||
Side string `json:"side"` // buy, sell
|
||||
Algorithm string `json:"algorithm"` // smart_route, twap, sniper, limit, market
|
||||
LimitPrice string `json:"limit_price,omitempty"`
|
||||
TimeInForce string `json:"time_in_force,omitempty"`
|
||||
Duration string `json:"duration,omitempty"` // for TWAP: execution window
|
||||
Accounts map[string]string `json:"accounts"` // provider -> accountID
|
||||
MaxSlippage float64 `json:"max_slippage_bps,omitempty"` // max allowed slippage in bps
|
||||
}
|
||||
|
||||
// SplitPlan describes how an order will be split across providers.
|
||||
type SplitPlan struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
TotalQty string `json:"total_qty"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
Legs []SplitLeg `json:"legs"`
|
||||
EstimatedVWAP float64 `json:"estimated_vwap"`
|
||||
EstimatedFees float64 `json:"estimated_fees"`
|
||||
EstimatedNet float64 `json:"estimated_net"` // VWAP + fees
|
||||
Savings float64 `json:"savings_vs_single_venue"` // bps saved
|
||||
}
|
||||
|
||||
// SplitLeg is one portion of a split order sent to a single provider.
|
||||
type SplitLeg struct {
|
||||
Provider string `json:"provider"`
|
||||
AccountID string `json:"account_id"`
|
||||
Qty string `json:"qty"`
|
||||
EstimatedPrice float64 `json:"estimated_price"`
|
||||
EstimatedFee float64 `json:"estimated_fee"` // in quote currency
|
||||
BidPrice float64 `json:"bid_price,omitempty"`
|
||||
AskPrice float64 `json:"ask_price,omitempty"`
|
||||
Liquidity float64 `json:"available_liquidity,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionResult is the outcome of executing a split plan.
|
||||
type ExecutionResult struct {
|
||||
PlanID string `json:"plan_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
TotalQty string `json:"total_qty"`
|
||||
FilledQty string `json:"filled_qty"`
|
||||
VWAP float64 `json:"vwap"`
|
||||
TotalFees float64 `json:"total_fees"`
|
||||
NetPrice float64 `json:"net_price"` // VWAP + fees
|
||||
Legs []ExecutionLeg `json:"legs"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Latency string `json:"latency"`
|
||||
Status string `json:"status"` // filled, partial, failed
|
||||
}
|
||||
|
||||
// ExecutionLeg is the result of one split leg.
|
||||
type ExecutionLeg struct {
|
||||
Provider string `json:"provider"`
|
||||
OrderID string `json:"order_id"`
|
||||
Qty string `json:"qty"`
|
||||
FilledQty string `json:"filled_qty"`
|
||||
Price float64 `json:"price"`
|
||||
Fees float64 `json:"fees"`
|
||||
Status string `json:"status"`
|
||||
Latency string `json:"latency"`
|
||||
}
|
||||
|
||||
// ProviderCapability describes what a provider supports.
|
||||
type ProviderCapability struct {
|
||||
Name string `json:"name"`
|
||||
AssetClasses []string `json:"asset_classes"` // crypto, us_equity, forex, etc.
|
||||
OrderTypes []string `json:"order_types"` // market, limit, stop, smart_route, twap, etc.
|
||||
Features []string `json:"features"` // ach, wire, custody, staking, etc.
|
||||
MakerFee float64 `json:"maker_fee_bps"` // basis points
|
||||
TakerFee float64 `json:"taker_fee_bps"`
|
||||
MinOrderUSD float64 `json:"min_order_usd,omitempty"`
|
||||
MaxOrderUSD float64 `json:"max_order_usd,omitempty"`
|
||||
Latency string `json:"latency,omitempty"` // typical latency
|
||||
Status string `json:"status"` // active, degraded, down
|
||||
}
|
||||
|
||||
// ProviderFees for net-price routing.
|
||||
type ProviderFees struct {
|
||||
Provider string `json:"provider"`
|
||||
MakerBps float64 `json:"maker_bps"` // maker fee in basis points
|
||||
TakerBps float64 `json:"taker_bps"` // taker fee in basis points
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/broker/pkg/marketdata"
|
||||
)
|
||||
|
||||
// Server provides WebSocket market data streaming to clients.
|
||||
// Institutional clients can subscribe to real-time consolidated feeds.
|
||||
type Server struct {
|
||||
feed *marketdata.Feed
|
||||
clients map[*Client]bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Client represents a connected WebSocket client.
|
||||
type Client struct {
|
||||
conn http.ResponseWriter
|
||||
flusher http.Flusher
|
||||
subs map[string]bool // subscribed symbols
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Message is the envelope for all WebSocket messages.
|
||||
type Message struct {
|
||||
Type string `json:"type"` // subscribe, unsubscribe, ticker, bbo, error
|
||||
Channel string `json:"channel"` // ticker, orderbook, trades
|
||||
Symbol string `json:"symbol,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
func NewServer(feed *marketdata.Feed) *Server {
|
||||
return &Server{
|
||||
feed: feed,
|
||||
clients: make(map[*Client]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSSE serves Server-Sent Events for real-time market data.
|
||||
// SSE is simpler than WebSocket and works through proxies/load balancers.
|
||||
// Path: GET /api/v1/stream?symbols=AAPL,BTC/USD
|
||||
func (s *Server) HandleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
symbols := r.URL.Query()["symbols"]
|
||||
if len(symbols) == 0 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Subscribe to all requested symbols
|
||||
var unsubs []func()
|
||||
merged := make(chan *marketdata.Ticker, 64)
|
||||
|
||||
for _, sym := range symbols {
|
||||
ch, unsub := s.feed.Subscribe(sym)
|
||||
unsubs = append(unsubs, unsub)
|
||||
go func() {
|
||||
for t := range ch {
|
||||
select {
|
||||
case merged <- t:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
defer func() {
|
||||
for _, unsub := range unsubs {
|
||||
unsub()
|
||||
}
|
||||
}()
|
||||
|
||||
// Send initial snapshots
|
||||
for _, sym := range symbols {
|
||||
t, err := s.feed.GetTicker(sym)
|
||||
if err == nil {
|
||||
s.sendSSE(w, flusher, "ticker", t)
|
||||
}
|
||||
}
|
||||
|
||||
// Stream updates
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case ticker := <-merged:
|
||||
s.sendSSE(w, flusher, "ticker", ticker)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) sendSSE(w http.ResponseWriter, flusher http.Flusher, event string, data interface{}) {
|
||||
msg := Message{
|
||||
Type: event,
|
||||
Data: data,
|
||||
Time: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
w.Write([]byte("event: " + event + "\n"))
|
||||
w.Write([]byte("data: "))
|
||||
w.Write(b)
|
||||
w.Write([]byte("\n\n"))
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// HandleBBO returns the current best bid/offer for a symbol.
|
||||
// Path: GET /api/v1/bbo/{symbol}
|
||||
func (s *Server) HandleBBO(w http.ResponseWriter, r *http.Request) {
|
||||
// Symbol extracted by caller
|
||||
}
|
||||
|
||||
// StartPoller starts a background goroutine that polls provider snapshots
|
||||
// at the given interval and updates the consolidated feed.
|
||||
func (s *Server) StartPoller(ctx context.Context, interval time.Duration, pollFn func(ctx context.Context)) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
pollFn(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Alpaca-compatible client backed by Lux Broker API.
|
||||
*
|
||||
* Drop-in replacement for direct Alpaca API clients.
|
||||
* Routes all calls through the Lux Broker API for unified multi-provider access.
|
||||
*
|
||||
* Usage:
|
||||
* // Before: const alpaca = new AlpacaClient()
|
||||
* // After:
|
||||
* import { AlpacaCompat } from '@luxfi/broker/compat/alpaca'
|
||||
* const alpaca = new AlpacaCompat({ brokerUrl: 'http://localhost:8090', accountId: '...' })
|
||||
* // Same API surface as before
|
||||
* const positions = await alpaca.getPositions()
|
||||
*/
|
||||
import type { Account, Order, Position, Portfolio, Asset, MarketSnapshot } from '../index.js';
|
||||
export interface AlpacaCompatConfig {
|
||||
brokerUrl: string;
|
||||
accountId?: string;
|
||||
token?: string;
|
||||
provider?: string;
|
||||
}
|
||||
export declare class AlpacaCompat {
|
||||
private client;
|
||||
private provider;
|
||||
private accountId;
|
||||
constructor(config: AlpacaCompatConfig);
|
||||
setAccountId(id: string): void;
|
||||
getAccount(): Promise<Account>;
|
||||
getPositions(): Promise<Position[]>;
|
||||
getPosition(symbol: string): Promise<Position | undefined>;
|
||||
getOrders(params?: {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
}): Promise<Order[]>;
|
||||
placeOrder(order: {
|
||||
symbol: string;
|
||||
qty?: number | string;
|
||||
notional?: number | string;
|
||||
side: 'buy' | 'sell';
|
||||
type: 'market' | 'limit' | 'stop' | 'stop_limit';
|
||||
time_in_force: 'day' | 'gtc' | 'ioc' | 'fok';
|
||||
limit_price?: number | string;
|
||||
stop_price?: number | string;
|
||||
}): Promise<Order>;
|
||||
cancelOrder(orderId: string): Promise<void>;
|
||||
getMarketData(symbol: string): Promise<MarketSnapshot>;
|
||||
getAssets(params?: {
|
||||
status?: string;
|
||||
asset_class?: string;
|
||||
}): Promise<Asset[]>;
|
||||
getPortfolio(): Promise<Portfolio>;
|
||||
}
|
||||
Vendored
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Alpaca-compatible client backed by Lux Broker API.
|
||||
*
|
||||
* Drop-in replacement for direct Alpaca API clients.
|
||||
* Routes all calls through the Lux Broker API for unified multi-provider access.
|
||||
*
|
||||
* Usage:
|
||||
* // Before: const alpaca = new AlpacaClient()
|
||||
* // After:
|
||||
* import { AlpacaCompat } from '@luxfi/broker/compat/alpaca'
|
||||
* const alpaca = new AlpacaCompat({ brokerUrl: 'http://localhost:8090', accountId: '...' })
|
||||
* // Same API surface as before
|
||||
* const positions = await alpaca.getPositions()
|
||||
*/
|
||||
import { BrokerClient } from '../index.js';
|
||||
export class AlpacaCompat {
|
||||
client;
|
||||
provider;
|
||||
accountId;
|
||||
constructor(config) {
|
||||
this.client = new BrokerClient({
|
||||
baseUrl: config.brokerUrl,
|
||||
token: config.token,
|
||||
});
|
||||
this.provider = config.provider ?? 'alpaca';
|
||||
this.accountId = config.accountId ?? '';
|
||||
}
|
||||
setAccountId(id) {
|
||||
this.accountId = id;
|
||||
}
|
||||
async getAccount() {
|
||||
return this.client.getAccount(this.provider, this.accountId);
|
||||
}
|
||||
async getPositions() {
|
||||
const portfolio = await this.client.getPortfolio(this.provider, this.accountId);
|
||||
return portfolio.positions;
|
||||
}
|
||||
async getPosition(symbol) {
|
||||
const positions = await this.getPositions();
|
||||
return positions.find((p) => p.symbol === symbol);
|
||||
}
|
||||
async getOrders(params) {
|
||||
return this.client.listOrders(this.provider, this.accountId);
|
||||
}
|
||||
async placeOrder(order) {
|
||||
return this.client.createOrder(this.provider, this.accountId, {
|
||||
symbol: order.symbol,
|
||||
qty: order.qty ? String(order.qty) : undefined,
|
||||
notional: order.notional ? String(order.notional) : undefined,
|
||||
side: order.side,
|
||||
type: order.type,
|
||||
time_in_force: order.time_in_force,
|
||||
limit_price: order.limit_price ? String(order.limit_price) : undefined,
|
||||
stop_price: order.stop_price ? String(order.stop_price) : undefined,
|
||||
});
|
||||
}
|
||||
async cancelOrder(orderId) {
|
||||
return this.client.cancelOrder(this.provider, this.accountId, orderId);
|
||||
}
|
||||
async getMarketData(symbol) {
|
||||
return this.client.getSnapshot(this.provider, symbol);
|
||||
}
|
||||
async getAssets(params) {
|
||||
return this.client.listAssets(this.provider, { class: params?.asset_class });
|
||||
}
|
||||
async getPortfolio() {
|
||||
return this.client.getPortfolio(this.provider, this.accountId);
|
||||
}
|
||||
}
|
||||
Vendored
+250
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Lux Broker API Client
|
||||
*
|
||||
* Unified client for the Lux federated broker API.
|
||||
* Connects to brokerd which routes across Alpaca, IBKR, BitGo, FalconX, Finix.
|
||||
*
|
||||
* Usage:
|
||||
* const broker = new BrokerClient({ baseUrl: 'https://broker.lux.financial' })
|
||||
* const accounts = await broker.listAccounts()
|
||||
* const routes = await broker.getRoutes('AAPL', 'buy')
|
||||
* const order = await broker.createOrder('alpaca', accountId, { symbol: 'AAPL', qty: '1', side: 'buy', type: 'market', time_in_force: 'day' })
|
||||
*/
|
||||
export interface BrokerClientConfig {
|
||||
baseUrl: string;
|
||||
token?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
export interface Account {
|
||||
id: string;
|
||||
provider: string;
|
||||
provider_id: string;
|
||||
org_id: string;
|
||||
user_id?: string;
|
||||
account_number: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
account_type?: string;
|
||||
enabled_assets?: string[];
|
||||
identity?: Identity;
|
||||
contact?: Contact;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export interface Identity {
|
||||
given_name: string;
|
||||
family_name: string;
|
||||
date_of_birth: string;
|
||||
tax_id?: string;
|
||||
tax_id_type?: string;
|
||||
country_of_tax_residence?: string;
|
||||
}
|
||||
export interface Contact {
|
||||
email: string;
|
||||
phone?: string;
|
||||
street?: string[];
|
||||
city?: string;
|
||||
state?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
}
|
||||
export interface Portfolio {
|
||||
account_id: string;
|
||||
cash: string;
|
||||
equity: string;
|
||||
buying_power: string;
|
||||
portfolio_value: string;
|
||||
positions: Position[];
|
||||
}
|
||||
export interface Position {
|
||||
symbol: string;
|
||||
qty: string;
|
||||
avg_entry_price: string;
|
||||
market_value: string;
|
||||
current_price: string;
|
||||
unrealized_pl: string;
|
||||
side: string;
|
||||
asset_class: string;
|
||||
}
|
||||
export interface Order {
|
||||
id: string;
|
||||
provider: string;
|
||||
provider_id: string;
|
||||
account_id: string;
|
||||
symbol: string;
|
||||
qty?: string;
|
||||
notional?: string;
|
||||
side: string;
|
||||
type: string;
|
||||
time_in_force: string;
|
||||
limit_price?: string;
|
||||
stop_price?: string;
|
||||
status: string;
|
||||
filled_qty?: string;
|
||||
filled_avg_price?: string;
|
||||
asset_class?: string;
|
||||
created_at: string;
|
||||
filled_at?: string;
|
||||
}
|
||||
export interface CreateOrderRequest {
|
||||
symbol: string;
|
||||
qty?: string;
|
||||
notional?: string;
|
||||
side: 'buy' | 'sell';
|
||||
type: 'market' | 'limit' | 'stop' | 'stop_limit';
|
||||
time_in_force: 'day' | 'gtc' | 'ioc' | 'fok';
|
||||
limit_price?: string;
|
||||
stop_price?: string;
|
||||
}
|
||||
export interface Asset {
|
||||
id: string;
|
||||
provider: string;
|
||||
symbol: string;
|
||||
name: string;
|
||||
class: string;
|
||||
exchange?: string;
|
||||
status: string;
|
||||
tradable: boolean;
|
||||
fractionable: boolean;
|
||||
}
|
||||
export interface AggregatedAsset {
|
||||
symbol: string;
|
||||
name: string;
|
||||
class: string;
|
||||
providers: string[];
|
||||
}
|
||||
export interface RouteResult {
|
||||
provider: string;
|
||||
symbol: string;
|
||||
bid_price?: number;
|
||||
ask_price?: number;
|
||||
spread?: number;
|
||||
score: number;
|
||||
}
|
||||
export interface Transfer {
|
||||
id: string;
|
||||
provider: string;
|
||||
provider_id: string;
|
||||
account_id: string;
|
||||
type: string;
|
||||
direction: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
export interface CreateTransferRequest {
|
||||
type: string;
|
||||
direction: string;
|
||||
amount: string;
|
||||
relationship_id?: string;
|
||||
}
|
||||
export interface BankRelationship {
|
||||
id: string;
|
||||
provider: string;
|
||||
provider_id: string;
|
||||
account_id: string;
|
||||
bank_name?: string;
|
||||
account_owner_name: string;
|
||||
bank_account_type: string;
|
||||
status: string;
|
||||
}
|
||||
export interface MarketSnapshot {
|
||||
symbol: string;
|
||||
latest_trade?: Trade;
|
||||
latest_quote?: Quote;
|
||||
minute_bar?: Bar;
|
||||
daily_bar?: Bar;
|
||||
prev_daily_bar?: Bar;
|
||||
}
|
||||
export interface Trade {
|
||||
timestamp: string;
|
||||
price: number;
|
||||
size: number;
|
||||
exchange?: string;
|
||||
}
|
||||
export interface Quote {
|
||||
timestamp: string;
|
||||
bid_price: number;
|
||||
bid_size: number;
|
||||
ask_price: number;
|
||||
ask_size: number;
|
||||
}
|
||||
export interface Bar {
|
||||
timestamp: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
vwap?: number;
|
||||
trade_count?: number;
|
||||
}
|
||||
export interface MarketClock {
|
||||
timestamp: string;
|
||||
is_open: boolean;
|
||||
next_open: string;
|
||||
next_close: string;
|
||||
}
|
||||
declare class BrokerError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string);
|
||||
}
|
||||
export declare class BrokerClient {
|
||||
private baseUrl;
|
||||
private token?;
|
||||
private headers;
|
||||
constructor(config: BrokerClientConfig);
|
||||
private request;
|
||||
listProviders(): Promise<string[]>;
|
||||
listAccounts(provider?: string): Promise<Account[]>;
|
||||
getAccount(provider: string, accountId: string): Promise<Account>;
|
||||
createAccount(params: {
|
||||
provider?: string;
|
||||
given_name: string;
|
||||
family_name: string;
|
||||
email: string;
|
||||
date_of_birth?: string;
|
||||
tax_id?: string;
|
||||
phone?: string;
|
||||
street?: string[];
|
||||
city?: string;
|
||||
state?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
}): Promise<Account>;
|
||||
getPortfolio(provider: string, accountId: string): Promise<Portfolio>;
|
||||
listOrders(provider: string, accountId: string): Promise<Order[]>;
|
||||
createOrder(provider: string, accountId: string, order: CreateOrderRequest): Promise<Order>;
|
||||
getOrder(provider: string, accountId: string, orderId: string): Promise<Order>;
|
||||
cancelOrder(provider: string, accountId: string, orderId: string): Promise<void>;
|
||||
listTransfers(provider: string, accountId: string): Promise<Transfer[]>;
|
||||
createTransfer(provider: string, accountId: string, transfer: CreateTransferRequest): Promise<Transfer>;
|
||||
listBankRelationships(provider: string, accountId: string): Promise<BankRelationship[]>;
|
||||
createBankRelationship(provider: string, accountId: string, params: {
|
||||
account_owner_name: string;
|
||||
bank_account_type: string;
|
||||
bank_account_number: string;
|
||||
bank_routing_number: string;
|
||||
}): Promise<BankRelationship>;
|
||||
listAssets(provider: string, opts?: {
|
||||
class?: string;
|
||||
all?: boolean;
|
||||
}): Promise<Asset[]>;
|
||||
getAsset(provider: string, symbolOrId: string): Promise<Asset>;
|
||||
getRoutes(symbol: string, side?: 'buy' | 'sell'): Promise<RouteResult[]>;
|
||||
listAggregatedAssets(): Promise<AggregatedAsset[]>;
|
||||
getSnapshot(provider: string, symbol: string): Promise<MarketSnapshot>;
|
||||
getSnapshots(provider: string, symbols: string[]): Promise<Record<string, MarketSnapshot>>;
|
||||
getBars(provider: string, symbol: string, opts?: {
|
||||
timeframe?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
limit?: number;
|
||||
}): Promise<Bar[]>;
|
||||
getLatestTrades(provider: string, symbols: string[]): Promise<Record<string, Trade>>;
|
||||
getLatestQuotes(provider: string, symbols: string[]): Promise<Record<string, Quote>>;
|
||||
getClock(provider: string): Promise<MarketClock>;
|
||||
}
|
||||
export { BrokerError };
|
||||
export default BrokerClient;
|
||||
Vendored
+149
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Lux Broker API Client
|
||||
*
|
||||
* Unified client for the Lux federated broker API.
|
||||
* Connects to brokerd which routes across Alpaca, IBKR, BitGo, FalconX, Finix.
|
||||
*
|
||||
* Usage:
|
||||
* const broker = new BrokerClient({ baseUrl: 'https://broker.lux.financial' })
|
||||
* const accounts = await broker.listAccounts()
|
||||
* const routes = await broker.getRoutes('AAPL', 'buy')
|
||||
* const order = await broker.createOrder('alpaca', accountId, { symbol: 'AAPL', qty: '1', side: 'buy', type: 'market', time_in_force: 'day' })
|
||||
*/
|
||||
class BrokerError extends Error {
|
||||
status;
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = 'BrokerError';
|
||||
}
|
||||
}
|
||||
export class BrokerClient {
|
||||
baseUrl;
|
||||
token;
|
||||
headers;
|
||||
constructor(config) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '');
|
||||
this.token = config.token;
|
||||
this.headers = config.headers ?? {};
|
||||
}
|
||||
async request(method, path, body) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...this.headers,
|
||||
};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new BrokerError(res.status, text);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
// --- Providers ---
|
||||
async listProviders() {
|
||||
const res = await this.request('GET', '/providers');
|
||||
return res.providers;
|
||||
}
|
||||
// --- Accounts ---
|
||||
async listAccounts(provider) {
|
||||
const qs = provider ? `?provider=${provider}` : '';
|
||||
return this.request('GET', `/accounts${qs}`);
|
||||
}
|
||||
async getAccount(provider, accountId) {
|
||||
return this.request('GET', `/accounts/${provider}/${accountId}`);
|
||||
}
|
||||
async createAccount(params) {
|
||||
return this.request('POST', '/accounts', params);
|
||||
}
|
||||
// --- Portfolio ---
|
||||
async getPortfolio(provider, accountId) {
|
||||
return this.request('GET', `/accounts/${provider}/${accountId}/portfolio`);
|
||||
}
|
||||
// --- Orders ---
|
||||
async listOrders(provider, accountId) {
|
||||
return this.request('GET', `/accounts/${provider}/${accountId}/orders`);
|
||||
}
|
||||
async createOrder(provider, accountId, order) {
|
||||
return this.request('POST', `/accounts/${provider}/${accountId}/orders`, order);
|
||||
}
|
||||
async getOrder(provider, accountId, orderId) {
|
||||
return this.request('GET', `/accounts/${provider}/${accountId}/orders/${orderId}`);
|
||||
}
|
||||
async cancelOrder(provider, accountId, orderId) {
|
||||
await this.request('DELETE', `/accounts/${provider}/${accountId}/orders/${orderId}`);
|
||||
}
|
||||
// --- Transfers ---
|
||||
async listTransfers(provider, accountId) {
|
||||
return this.request('GET', `/accounts/${provider}/${accountId}/transfers`);
|
||||
}
|
||||
async createTransfer(provider, accountId, transfer) {
|
||||
return this.request('POST', `/accounts/${provider}/${accountId}/transfers`, transfer);
|
||||
}
|
||||
// --- Bank Relationships ---
|
||||
async listBankRelationships(provider, accountId) {
|
||||
return this.request('GET', `/accounts/${provider}/${accountId}/bank-relationships`);
|
||||
}
|
||||
async createBankRelationship(provider, accountId, params) {
|
||||
return this.request('POST', `/accounts/${provider}/${accountId}/bank-relationships`, params);
|
||||
}
|
||||
// --- Assets ---
|
||||
async listAssets(provider, opts) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.class)
|
||||
params.set('class', opts.class);
|
||||
if (opts?.all)
|
||||
params.set('all', '1');
|
||||
const qs = params.toString() ? `?${params}` : '';
|
||||
return this.request('GET', `/assets/${provider}${qs}`);
|
||||
}
|
||||
async getAsset(provider, symbolOrId) {
|
||||
return this.request('GET', `/assets/${provider}/${encodeURIComponent(symbolOrId)}`);
|
||||
}
|
||||
// --- Smart Order Routing ---
|
||||
async getRoutes(symbol, side = 'buy') {
|
||||
// Handle crypto pairs with / separator
|
||||
const path = symbol.includes('/') ? `/route/${symbol}` : `/route/${symbol}`;
|
||||
return this.request('GET', `${path}?side=${side}`);
|
||||
}
|
||||
async listAggregatedAssets() {
|
||||
return this.request('GET', '/assets');
|
||||
}
|
||||
// --- Market Data ---
|
||||
async getSnapshot(provider, symbol) {
|
||||
return this.request('GET', `/market/${provider}/snapshot/${symbol}`);
|
||||
}
|
||||
async getSnapshots(provider, symbols) {
|
||||
return this.request('GET', `/market/${provider}/snapshots?symbols=${symbols.join(',')}`);
|
||||
}
|
||||
async getBars(provider, symbol, opts) {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.timeframe)
|
||||
params.set('timeframe', opts.timeframe);
|
||||
if (opts?.start)
|
||||
params.set('start', opts.start);
|
||||
if (opts?.end)
|
||||
params.set('end', opts.end);
|
||||
if (opts?.limit)
|
||||
params.set('limit', String(opts.limit));
|
||||
const qs = params.toString() ? `?${params}` : '';
|
||||
return this.request('GET', `/market/${provider}/bars/${symbol}${qs}`);
|
||||
}
|
||||
async getLatestTrades(provider, symbols) {
|
||||
return this.request('GET', `/market/${provider}/trades/latest?symbols=${symbols.join(',')}`);
|
||||
}
|
||||
async getLatestQuotes(provider, symbols) {
|
||||
return this.request('GET', `/market/${provider}/quotes/latest?symbols=${symbols.join(',')}`);
|
||||
}
|
||||
async getClock(provider) {
|
||||
return this.request('GET', `/market/${provider}/clock`);
|
||||
}
|
||||
}
|
||||
export { BrokerError };
|
||||
export default BrokerClient;
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@luxfi/broker",
|
||||
"version": "0.1.0",
|
||||
"description": "Lux Broker API client — unified access to Alpaca, IBKR, BitGo, FalconX, Finix",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./compat/alpaca": {
|
||||
"import": "./dist/compat/alpaca.js",
|
||||
"types": "./dist/compat/alpaca.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
Generated
+24
@@ -0,0 +1,24 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.5.0
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Alpaca-compatible client backed by Lux Broker API.
|
||||
*
|
||||
* Drop-in replacement for direct Alpaca API clients.
|
||||
* Routes all calls through the Lux Broker API for unified multi-provider access.
|
||||
*
|
||||
* Usage:
|
||||
* // Before: const alpaca = new AlpacaClient()
|
||||
* // After:
|
||||
* import { AlpacaCompat } from '@luxfi/broker/compat/alpaca'
|
||||
* const alpaca = new AlpacaCompat({ brokerUrl: 'http://localhost:8090', accountId: '...' })
|
||||
* // Same API surface as before
|
||||
* const positions = await alpaca.getPositions()
|
||||
*/
|
||||
|
||||
import { BrokerClient } from '../index.js'
|
||||
import type { Account, Order, Position, Portfolio, Asset, MarketSnapshot } from '../index.js'
|
||||
|
||||
export interface AlpacaCompatConfig {
|
||||
brokerUrl: string
|
||||
accountId?: string
|
||||
token?: string
|
||||
provider?: string // defaults to 'alpaca'
|
||||
}
|
||||
|
||||
export class AlpacaCompat {
|
||||
private client: BrokerClient
|
||||
private provider: string
|
||||
private accountId: string
|
||||
|
||||
constructor(config: AlpacaCompatConfig) {
|
||||
this.client = new BrokerClient({
|
||||
baseUrl: config.brokerUrl,
|
||||
token: config.token,
|
||||
})
|
||||
this.provider = config.provider ?? 'alpaca'
|
||||
this.accountId = config.accountId ?? ''
|
||||
}
|
||||
|
||||
setAccountId(id: string) {
|
||||
this.accountId = id
|
||||
}
|
||||
|
||||
async getAccount(): Promise<Account> {
|
||||
return this.client.getAccount(this.provider, this.accountId)
|
||||
}
|
||||
|
||||
async getPositions(): Promise<Position[]> {
|
||||
const portfolio = await this.client.getPortfolio(this.provider, this.accountId)
|
||||
return portfolio.positions
|
||||
}
|
||||
|
||||
async getPosition(symbol: string): Promise<Position | undefined> {
|
||||
const positions = await this.getPositions()
|
||||
return positions.find((p) => p.symbol === symbol)
|
||||
}
|
||||
|
||||
async getOrders(params?: { status?: string; limit?: number }): Promise<Order[]> {
|
||||
return this.client.listOrders(this.provider, this.accountId)
|
||||
}
|
||||
|
||||
async placeOrder(order: {
|
||||
symbol: string
|
||||
qty?: number | string
|
||||
notional?: number | string
|
||||
side: 'buy' | 'sell'
|
||||
type: 'market' | 'limit' | 'stop' | 'stop_limit'
|
||||
time_in_force: 'day' | 'gtc' | 'ioc' | 'fok'
|
||||
limit_price?: number | string
|
||||
stop_price?: number | string
|
||||
}): Promise<Order> {
|
||||
return this.client.createOrder(this.provider, this.accountId, {
|
||||
symbol: order.symbol,
|
||||
qty: order.qty ? String(order.qty) : undefined,
|
||||
notional: order.notional ? String(order.notional) : undefined,
|
||||
side: order.side,
|
||||
type: order.type,
|
||||
time_in_force: order.time_in_force,
|
||||
limit_price: order.limit_price ? String(order.limit_price) : undefined,
|
||||
stop_price: order.stop_price ? String(order.stop_price) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async cancelOrder(orderId: string): Promise<void> {
|
||||
return this.client.cancelOrder(this.provider, this.accountId, orderId)
|
||||
}
|
||||
|
||||
async getMarketData(symbol: string): Promise<MarketSnapshot> {
|
||||
return this.client.getSnapshot(this.provider, symbol)
|
||||
}
|
||||
|
||||
async getAssets(params?: { status?: string; asset_class?: string }): Promise<Asset[]> {
|
||||
return this.client.listAssets(this.provider, { class: params?.asset_class })
|
||||
}
|
||||
|
||||
async getPortfolio(): Promise<Portfolio> {
|
||||
return this.client.getPortfolio(this.provider, this.accountId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Lux Broker API Client
|
||||
*
|
||||
* Unified client for the Lux federated broker API.
|
||||
* Connects to brokerd which routes across Alpaca, IBKR, BitGo, FalconX, Finix.
|
||||
*
|
||||
* Usage:
|
||||
* const broker = new BrokerClient({ baseUrl: 'https://broker.lux.financial' })
|
||||
* const accounts = await broker.listAccounts()
|
||||
* const routes = await broker.getRoutes('AAPL', 'buy')
|
||||
* const order = await broker.createOrder('alpaca', accountId, { symbol: 'AAPL', qty: '1', side: 'buy', type: 'market', time_in_force: 'day' })
|
||||
*/
|
||||
|
||||
export interface BrokerClientConfig {
|
||||
baseUrl: string
|
||||
token?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
provider: string
|
||||
provider_id: string
|
||||
org_id: string
|
||||
user_id?: string
|
||||
account_number: string
|
||||
status: string
|
||||
currency: string
|
||||
account_type?: string
|
||||
enabled_assets?: string[]
|
||||
identity?: Identity
|
||||
contact?: Contact
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Identity {
|
||||
given_name: string
|
||||
family_name: string
|
||||
date_of_birth: string
|
||||
tax_id?: string
|
||||
tax_id_type?: string
|
||||
country_of_tax_residence?: string
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
email: string
|
||||
phone?: string
|
||||
street?: string[]
|
||||
city?: string
|
||||
state?: string
|
||||
postal_code?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
account_id: string
|
||||
cash: string
|
||||
equity: string
|
||||
buying_power: string
|
||||
portfolio_value: string
|
||||
positions: Position[]
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
symbol: string
|
||||
qty: string
|
||||
avg_entry_price: string
|
||||
market_value: string
|
||||
current_price: string
|
||||
unrealized_pl: string
|
||||
side: string
|
||||
asset_class: string
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
provider: string
|
||||
provider_id: string
|
||||
account_id: string
|
||||
symbol: string
|
||||
qty?: string
|
||||
notional?: string
|
||||
side: string
|
||||
type: string
|
||||
time_in_force: string
|
||||
limit_price?: string
|
||||
stop_price?: string
|
||||
status: string
|
||||
filled_qty?: string
|
||||
filled_avg_price?: string
|
||||
asset_class?: string
|
||||
created_at: string
|
||||
filled_at?: string
|
||||
}
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
symbol: string
|
||||
qty?: string
|
||||
notional?: string
|
||||
side: 'buy' | 'sell'
|
||||
type: 'market' | 'limit' | 'stop' | 'stop_limit'
|
||||
time_in_force: 'day' | 'gtc' | 'ioc' | 'fok'
|
||||
limit_price?: string
|
||||
stop_price?: string
|
||||
}
|
||||
|
||||
export interface Asset {
|
||||
id: string
|
||||
provider: string
|
||||
symbol: string
|
||||
name: string
|
||||
class: string
|
||||
exchange?: string
|
||||
status: string
|
||||
tradable: boolean
|
||||
fractionable: boolean
|
||||
}
|
||||
|
||||
export interface AggregatedAsset {
|
||||
symbol: string
|
||||
name: string
|
||||
class: string
|
||||
providers: string[]
|
||||
}
|
||||
|
||||
export interface RouteResult {
|
||||
provider: string
|
||||
symbol: string
|
||||
bid_price?: number
|
||||
ask_price?: number
|
||||
spread?: number
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface Transfer {
|
||||
id: string
|
||||
provider: string
|
||||
provider_id: string
|
||||
account_id: string
|
||||
type: string
|
||||
direction: string
|
||||
amount: string
|
||||
currency: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CreateTransferRequest {
|
||||
type: string
|
||||
direction: string
|
||||
amount: string
|
||||
relationship_id?: string
|
||||
}
|
||||
|
||||
export interface BankRelationship {
|
||||
id: string
|
||||
provider: string
|
||||
provider_id: string
|
||||
account_id: string
|
||||
bank_name?: string
|
||||
account_owner_name: string
|
||||
bank_account_type: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface MarketSnapshot {
|
||||
symbol: string
|
||||
latest_trade?: Trade
|
||||
latest_quote?: Quote
|
||||
minute_bar?: Bar
|
||||
daily_bar?: Bar
|
||||
prev_daily_bar?: Bar
|
||||
}
|
||||
|
||||
export interface Trade {
|
||||
timestamp: string
|
||||
price: number
|
||||
size: number
|
||||
exchange?: string
|
||||
}
|
||||
|
||||
export interface Quote {
|
||||
timestamp: string
|
||||
bid_price: number
|
||||
bid_size: number
|
||||
ask_price: number
|
||||
ask_size: number
|
||||
}
|
||||
|
||||
export interface Bar {
|
||||
timestamp: string
|
||||
open: number
|
||||
high: number
|
||||
low: number
|
||||
close: number
|
||||
volume: number
|
||||
vwap?: number
|
||||
trade_count?: number
|
||||
}
|
||||
|
||||
export interface MarketClock {
|
||||
timestamp: string
|
||||
is_open: boolean
|
||||
next_open: string
|
||||
next_close: string
|
||||
}
|
||||
|
||||
class BrokerError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'BrokerError'
|
||||
}
|
||||
}
|
||||
|
||||
export class BrokerClient {
|
||||
private baseUrl: string
|
||||
private token?: string
|
||||
private headers: Record<string, string>
|
||||
|
||||
constructor(config: BrokerClientConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '')
|
||||
this.token = config.token
|
||||
this.headers = config.headers ?? {}
|
||||
}
|
||||
|
||||
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...this.headers,
|
||||
}
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new BrokerError(res.status, text)
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// --- Providers ---
|
||||
|
||||
async listProviders(): Promise<string[]> {
|
||||
const res = await this.request<{ providers: string[] }>('GET', '/providers')
|
||||
return res.providers
|
||||
}
|
||||
|
||||
// --- Accounts ---
|
||||
|
||||
async listAccounts(provider?: string): Promise<Account[]> {
|
||||
const qs = provider ? `?provider=${provider}` : ''
|
||||
return this.request('GET', `/accounts${qs}`)
|
||||
}
|
||||
|
||||
async getAccount(provider: string, accountId: string): Promise<Account> {
|
||||
return this.request('GET', `/accounts/${provider}/${accountId}`)
|
||||
}
|
||||
|
||||
async createAccount(params: {
|
||||
provider?: string
|
||||
given_name: string
|
||||
family_name: string
|
||||
email: string
|
||||
date_of_birth?: string
|
||||
tax_id?: string
|
||||
phone?: string
|
||||
street?: string[]
|
||||
city?: string
|
||||
state?: string
|
||||
postal_code?: string
|
||||
country?: string
|
||||
}): Promise<Account> {
|
||||
return this.request('POST', '/accounts', params)
|
||||
}
|
||||
|
||||
// --- Portfolio ---
|
||||
|
||||
async getPortfolio(provider: string, accountId: string): Promise<Portfolio> {
|
||||
return this.request('GET', `/accounts/${provider}/${accountId}/portfolio`)
|
||||
}
|
||||
|
||||
// --- Orders ---
|
||||
|
||||
async listOrders(provider: string, accountId: string): Promise<Order[]> {
|
||||
return this.request('GET', `/accounts/${provider}/${accountId}/orders`)
|
||||
}
|
||||
|
||||
async createOrder(provider: string, accountId: string, order: CreateOrderRequest): Promise<Order> {
|
||||
return this.request('POST', `/accounts/${provider}/${accountId}/orders`, order)
|
||||
}
|
||||
|
||||
async getOrder(provider: string, accountId: string, orderId: string): Promise<Order> {
|
||||
return this.request('GET', `/accounts/${provider}/${accountId}/orders/${orderId}`)
|
||||
}
|
||||
|
||||
async cancelOrder(provider: string, accountId: string, orderId: string): Promise<void> {
|
||||
await this.request('DELETE', `/accounts/${provider}/${accountId}/orders/${orderId}`)
|
||||
}
|
||||
|
||||
// --- Transfers ---
|
||||
|
||||
async listTransfers(provider: string, accountId: string): Promise<Transfer[]> {
|
||||
return this.request('GET', `/accounts/${provider}/${accountId}/transfers`)
|
||||
}
|
||||
|
||||
async createTransfer(provider: string, accountId: string, transfer: CreateTransferRequest): Promise<Transfer> {
|
||||
return this.request('POST', `/accounts/${provider}/${accountId}/transfers`, transfer)
|
||||
}
|
||||
|
||||
// --- Bank Relationships ---
|
||||
|
||||
async listBankRelationships(provider: string, accountId: string): Promise<BankRelationship[]> {
|
||||
return this.request('GET', `/accounts/${provider}/${accountId}/bank-relationships`)
|
||||
}
|
||||
|
||||
async createBankRelationship(
|
||||
provider: string,
|
||||
accountId: string,
|
||||
params: {
|
||||
account_owner_name: string
|
||||
bank_account_type: string
|
||||
bank_account_number: string
|
||||
bank_routing_number: string
|
||||
},
|
||||
): Promise<BankRelationship> {
|
||||
return this.request('POST', `/accounts/${provider}/${accountId}/bank-relationships`, params)
|
||||
}
|
||||
|
||||
// --- Assets ---
|
||||
|
||||
async listAssets(provider: string, opts?: { class?: string; all?: boolean }): Promise<Asset[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (opts?.class) params.set('class', opts.class)
|
||||
if (opts?.all) params.set('all', '1')
|
||||
const qs = params.toString() ? `?${params}` : ''
|
||||
return this.request('GET', `/assets/${provider}${qs}`)
|
||||
}
|
||||
|
||||
async getAsset(provider: string, symbolOrId: string): Promise<Asset> {
|
||||
return this.request('GET', `/assets/${provider}/${encodeURIComponent(symbolOrId)}`)
|
||||
}
|
||||
|
||||
// --- Smart Order Routing ---
|
||||
|
||||
async getRoutes(symbol: string, side: 'buy' | 'sell' = 'buy'): Promise<RouteResult[]> {
|
||||
// Handle crypto pairs with / separator
|
||||
const path = symbol.includes('/') ? `/route/${symbol}` : `/route/${symbol}`
|
||||
return this.request('GET', `${path}?side=${side}`)
|
||||
}
|
||||
|
||||
async listAggregatedAssets(): Promise<AggregatedAsset[]> {
|
||||
return this.request('GET', '/assets')
|
||||
}
|
||||
|
||||
// --- Market Data ---
|
||||
|
||||
async getSnapshot(provider: string, symbol: string): Promise<MarketSnapshot> {
|
||||
return this.request('GET', `/market/${provider}/snapshot/${symbol}`)
|
||||
}
|
||||
|
||||
async getSnapshots(provider: string, symbols: string[]): Promise<Record<string, MarketSnapshot>> {
|
||||
return this.request('GET', `/market/${provider}/snapshots?symbols=${symbols.join(',')}`)
|
||||
}
|
||||
|
||||
async getBars(
|
||||
provider: string,
|
||||
symbol: string,
|
||||
opts?: { timeframe?: string; start?: string; end?: string; limit?: number },
|
||||
): Promise<Bar[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (opts?.timeframe) params.set('timeframe', opts.timeframe)
|
||||
if (opts?.start) params.set('start', opts.start)
|
||||
if (opts?.end) params.set('end', opts.end)
|
||||
if (opts?.limit) params.set('limit', String(opts.limit))
|
||||
const qs = params.toString() ? `?${params}` : ''
|
||||
return this.request('GET', `/market/${provider}/bars/${symbol}${qs}`)
|
||||
}
|
||||
|
||||
async getLatestTrades(provider: string, symbols: string[]): Promise<Record<string, Trade>> {
|
||||
return this.request('GET', `/market/${provider}/trades/latest?symbols=${symbols.join(',')}`)
|
||||
}
|
||||
|
||||
async getLatestQuotes(provider: string, symbols: string[]): Promise<Record<string, Quote>> {
|
||||
return this.request('GET', `/market/${provider}/quotes/latest?symbols=${symbols.join(',')}`)
|
||||
}
|
||||
|
||||
async getClock(provider: string): Promise<MarketClock> {
|
||||
return this.request('GET', `/market/${provider}/clock`)
|
||||
}
|
||||
}
|
||||
|
||||
export { BrokerError }
|
||||
export default BrokerClient
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user