mirror of
https://github.com/luxfi/broker.git
synced 2026-10-30 14:45:05 +00:00
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
190 lines
5.3 KiB
Go
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
|
|
}
|