Files
broker/pkg/api/server.go
T

919 lines
25 KiB
Go

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", "http://localhost:3001"},
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})
}