Files
zeekay bf09f371b2 fix: red team findings — 5 critical + 3 high from Alpaca sign-off review
C1: queryCountryInfos endpoint typo (-1 suffix removed)
C2: ValidateTIF wired into CreateOrder (was dead code)
C3: GetFixedIncomeQuote returns honest error (Moment ToS required)
C4: Agreement signing uses real client IP (was 0.0.0.0)
C5: Overnight trading uses America/New_York timezone (was UTC-4)
H1: Background asset sync goroutine (1-hour ticker)
H2: ACATS disclosure API endpoint
H3: Crypto $200K aggregate limit on split plans
2026-04-06 15:53:47 -07:00

190 lines
5.3 KiB
Go

package alpaca
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/luxfi/broker/pkg/types"
)
// --- AccountManager implementation ---
func (p *Provider) UpdateAccount(ctx context.Context, accountID string, req *types.UpdateAccountRequest) (*types.Account, error) {
body := make(map[string]interface{})
if req.Contact != nil {
contact := make(map[string]interface{})
if req.Contact.Email != "" {
contact["email_address"] = req.Contact.Email
}
if req.Contact.Phone != "" {
contact["phone_number"] = req.Contact.Phone
}
if len(req.Contact.Street) > 0 {
contact["street_address"] = req.Contact.Street
}
if req.Contact.City != "" {
contact["city"] = req.Contact.City
}
if req.Contact.State != "" {
contact["state"] = req.Contact.State
}
if req.Contact.PostalCode != "" {
contact["postal_code"] = req.Contact.PostalCode
}
if req.Contact.Country != "" {
contact["country"] = req.Contact.Country
}
body["contact"] = contact
}
if req.Identity != nil {
identity := make(map[string]interface{})
if req.Identity.GivenName != "" {
identity["given_name"] = req.Identity.GivenName
}
if req.Identity.FamilyName != "" {
identity["family_name"] = req.Identity.FamilyName
}
if req.Identity.DateOfBirth != "" {
identity["date_of_birth"] = req.Identity.DateOfBirth
}
body["identity"] = identity
}
if len(req.EnabledAssets) > 0 {
body["enabled_assets"] = req.EnabledAssets
}
data, _, err := p.do(ctx, http.MethodPatch, "/v1/accounts/"+accountID, 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) CloseAccount(ctx context.Context, accountID string) error {
_, _, err := p.do(ctx, http.MethodDelete, "/v1/accounts/"+accountID, nil)
return err
}
func (p *Provider) GetAccountActivities(ctx context.Context, accountID string, params *types.ActivityParams) ([]*types.Activity, error) {
path := "/v1/accounts/" + accountID + "/activities"
sep := "?"
if params != nil {
if len(params.ActivityTypes) > 0 {
path += sep + "activity_type=" + strings.Join(params.ActivityTypes, ",")
sep = "&"
}
if params.Date != "" {
path += sep + "date=" + params.Date
sep = "&"
}
if params.After != "" {
path += sep + "after=" + params.After
sep = "&"
}
if params.Until != "" {
path += sep + "until=" + params.Until
sep = "&"
}
if params.Direction != "" {
path += sep + "direction=" + params.Direction
sep = "&"
}
if params.PageSize > 0 {
path += sep + "page_size=" + fmt.Sprintf("%d", params.PageSize)
sep = "&"
}
if params.PageToken != "" {
path += sep + "page_token=" + params.PageToken
}
}
data, _, err := p.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var raw []struct {
ID string `json:"id"`
AccountID string `json:"account_id"`
ActivityType string `json:"activity_type"`
Symbol string `json:"symbol"`
Side string `json:"side"`
Qty string `json:"qty"`
Price string `json:"price"`
CumQty string `json:"cum_qty"`
LeavesQty string `json:"leaves_qty"`
NetAmount string `json:"net_amount"`
PerShareAmount string `json:"per_share_amount"`
Description string `json:"description"`
Status string `json:"status"`
TransactionTime string `json:"transaction_time"`
Date string `json:"date"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
activities := make([]*types.Activity, len(raw))
for i, r := range raw {
activities[i] = &types.Activity{
ID: r.ID,
AccountID: r.AccountID,
ActivityType: r.ActivityType,
Symbol: r.Symbol,
Side: r.Side,
Qty: r.Qty,
Price: r.Price,
CumQty: r.CumQty,
LeavesQty: r.LeavesQty,
NetAmount: r.NetAmount,
PerShareAmount: r.PerShareAmount,
Description: r.Description,
Status: r.Status,
TransactionTime: r.TransactionTime,
Date: r.Date,
}
}
return activities, nil
}
// AddCryptoAgreement PATCHes a crypto_agreement onto an existing account.
// ipAddress must be the real client IP from the HTTP request (e.g. r.RemoteAddr
// or X-Forwarded-For). Alpaca requires a valid IP for regulatory audit trail.
func (p *Provider) AddCryptoAgreement(ctx context.Context, accountID, ipAddress string) error {
if ipAddress == "" {
return fmt.Errorf("client IP address is required for agreement signing")
}
body := map[string]interface{}{
"agreements": []map[string]interface{}{
{
"agreement": "crypto_agreement",
"signed_at": time.Now().UTC().Format(time.RFC3339),
"ip_address": ipAddress,
},
},
}
_, _, err := p.do(ctx, http.MethodPatch, "/v1/accounts/"+accountID, body)
return err
}
// GetCountryInfo returns country eligibility and restriction data for brokerage.
func (p *Provider) GetCountryInfo(ctx context.Context) ([]types.CountryInfo, error) {
data, _, err := p.do(ctx, http.MethodGet, "/v1/reference/queryCountryInfos", nil)
if err != nil {
return nil, err
}
var raw []types.CountryInfo
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
return raw, nil
}