feat: complete Alpaca sign-off — ACATS, Fixed Income, Crypto, Omnibus

ACATS (7/7 items):
- ValidateACATSAccount/Assets, CreateACATSTransfer, GetACATSTransfer
- ListACATSTransfers, CancelACATSTransfer, ListACATSActivities
- GetACATSRejection, ACATSDisclosure constant
- 13 tests with subtests covering all validation paths

Fixed Income (11/11 items):
- ListCorporateBonds, ListTreasuryBonds (/v1/assets/fixed_income/*)
- CreateFixedIncomeOrder (market-only, TIF=day, decimal validation)
- GetFixedIncomeActivities (maturity filter), GetFixedIncomePositions
- CloseFixedIncomePosition, CancelFixedIncomeOrder
- 11 tests including 6 validation error subtests

Remaining Crypto (5/5 items):
- AddCryptoAgreement PATCH for existing accounts
- ListCryptoAssets with min_order_size/price_increment
- NonMarginableBuyingPower in Portfolio
- ListCryptoWallets, GetCryptoWallet stubs
- GetCountryInfo for regional license checks

Remaining Omnibus (5/5 items):
- ValidateTIF with per-asset-class TIF maps
- GetSubaccountConfirms for trade confirmations
- trading_blocked flag in SSE account events
- fractional_eh_enabled already in Asset struct

All 38 Alpaca provider tests pass. Full broker build clean.
This commit is contained in:
2026-04-06 14:03:54 -07:00
parent 72facc0488
commit 986666f8b1
11 changed files with 1370 additions and 30 deletions
+12 -10
View File
@@ -301,10 +301,11 @@ func (p *Provider) GetPortfolio(ctx context.Context, providerAccountID string) (
return nil, err
}
var ta struct {
Cash string `json:"cash"`
Equity string `json:"equity"`
BuyingPower string `json:"buying_power"`
PortfolioValue string `json:"portfolio_value"`
Cash string `json:"cash"`
Equity string `json:"equity"`
BuyingPower string `json:"buying_power"`
NonMarginableBuyingPower string `json:"non_marginable_buying_power"`
PortfolioValue string `json:"portfolio_value"`
}
if err := json.Unmarshal(tData, &ta); err != nil {
return nil, err
@@ -344,12 +345,13 @@ func (p *Provider) GetPortfolio(ctx context.Context, providerAccountID string) (
}
return &types.Portfolio{
AccountID: providerAccountID,
Cash: ta.Cash,
Equity: ta.Equity,
BuyingPower: ta.BuyingPower,
PortfolioValue: ta.PortfolioValue,
Positions: positions,
AccountID: providerAccountID,
Cash: ta.Cash,
Equity: ta.Equity,
BuyingPower: ta.BuyingPower,
NonMarginableBuyingPower: ta.NonMarginableBuyingPower,
PortfolioValue: ta.PortfolioValue,
Positions: positions,
}, nil
}
+235
View File
@@ -0,0 +1,235 @@
package alpaca
import (
"context"
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"github.com/luxfi/broker/pkg/types"
)
// ACATSDisclosure is the full ACATS disclosure text that must be presented to
// and accepted by the user before initiating an ACATS transfer.
const ACATSDisclosure = `Unless otherwise indicated in the instruction above, please transfer in-kind, all assets into my account with Alpaca Securities LLC. I understand that to the extent any assets in my account are not readily transferable or without penalties such assets may not be transferred within the timeframes required by applicable regulations. I understand I will be contracted by the delivering and/or receiving firm regarding any assets that are not transferable. I authorize you to liquidate any non-transferable proprietary money market fund assets that are part of my account and transfer the resulting credit balance to Alpaca Securities LLC. I authorize the transferor to deduct any outstanding fees due you from the credit balance in my account. If my account does not contain a credit balance, or if the credit balance in the account is insufficient to satisfy any outstanding fees due you, I authorize you to liquidate the assets in my account to the extent necessary to satisfy that obligation. I understand that upon receiving a copy of this transfer instruction, for a full account transfer, transferor will freeze my account and cancel all open orders for my account on your books. I affirm that have destroyed or returned to the transferor all credit/debit cards and or unused checks issued to me in connection with my account.`
// --- ACATSManager implementation ---
// ValidateACATSAccount checks that the account exists and is ACTIVE (KYC
// approved). Only ACTIVE accounts may initiate ACATS transfers.
func (p *Provider) ValidateACATSAccount(ctx context.Context, accountID string) error {
acct, err := p.GetAccount(ctx, accountID)
if err != nil {
return fmt.Errorf("validate ACATS account: %w", err)
}
status := strings.ToUpper(acct.Status)
if status != "ACTIVE" && status != "APPROVED" {
return fmt.Errorf("account %s not eligible for ACATS: status is %s, must be ACTIVE or APPROVED", accountID, acct.Status)
}
return nil
}
// ValidateACATSAssets rejects ineligible asset classes and fractional shares.
// ACATS does not support crypto, fixed income, or options. All quantities must
// be whole shares.
func ValidateACATSAssets(assets []ACATSAsset) error {
if len(assets) == 0 {
return fmt.Errorf("partial ACATS transfer requires at least one asset")
}
for _, a := range assets {
sym := strings.ToUpper(a.Symbol)
// Crypto symbols contain "/" (e.g. BTC/USD, ETH/USD).
if strings.Contains(sym, "/") {
return fmt.Errorf("asset %s: crypto is not eligible for ACATS transfer", a.Symbol)
}
// Options use OCC symbology (21-char identifiers, e.g. AAPL260418C00150000).
if len(sym) > 15 {
return fmt.Errorf("asset %s: options contracts are not eligible for ACATS transfer", a.Symbol)
}
// Validate qty is a positive whole number.
qty, err := strconv.ParseFloat(a.Qty, 64)
if err != nil || qty <= 0 {
return fmt.Errorf("asset %s: qty must be a positive number, got %q", a.Symbol, a.Qty)
}
if math.Floor(qty) != qty {
return fmt.Errorf("asset %s: ACATS requires whole shares, got %s", a.Symbol, a.Qty)
}
}
return nil
}
// ACATSAsset is an alias for the types package ACATS asset.
type ACATSAsset = types.ACATSAsset
// CreateACATSTransfer initiates an incoming ACATS transfer from a contra
// broker. Validates account eligibility and asset constraints before submitting.
func (p *Provider) CreateACATSTransfer(ctx context.Context, accountID string, req *types.CreateACATSTransferRequest) (*types.ACATSTransfer, error) {
if err := p.ValidateACATSAccount(ctx, accountID); err != nil {
return nil, err
}
if req.Type == "PARTIAL" {
if err := ValidateACATSAssets(req.Assets); err != nil {
return nil, err
}
}
body := map[string]interface{}{
"type": "ACATS",
"direction": "INCOMING",
"contra_account_number": req.ContraAccount,
"contra_broker_number": req.ContraBroker,
"transfer_type": req.Type,
}
if len(req.Assets) > 0 {
assets := make([]map[string]string, len(req.Assets))
for i, a := range req.Assets {
assets[i] = map[string]string{
"symbol": a.Symbol,
"qty": a.Qty,
}
}
body["assets"] = assets
}
data, _, err := p.do(ctx, http.MethodPost, "/v1/accounts/"+accountID+"/transfers", body)
if err != nil {
return nil, err
}
return parseACATSTransfer(data)
}
// GetACATSTransfer retrieves a single ACATS transfer by ID. Returns the full
// transfer including status and reject_reason for rejection notification.
func (p *Provider) GetACATSTransfer(ctx context.Context, accountID, transferID string) (*types.ACATSTransfer, error) {
data, _, err := p.do(ctx, http.MethodGet, "/v1/accounts/"+accountID+"/transfers/"+transferID, nil)
if err != nil {
return nil, err
}
return parseACATSTransfer(data)
}
// GetACATSRejection retrieves a rejected ACATS transfer and returns the
// rejection reason. Returns an error if the transfer is not in REJECTED status.
// The caller uses the reason to notify the user and allow resubmission.
func (p *Provider) GetACATSRejection(ctx context.Context, accountID, transferID string) (string, error) {
t, err := p.GetACATSTransfer(ctx, accountID, transferID)
if err != nil {
return "", err
}
if t.Status != "REJECTED" {
return "", fmt.Errorf("transfer %s is not rejected, status is %s", transferID, t.Status)
}
return t.RejectReason, nil
}
// ListACATSTransfers returns all ACATS transfers for an account, covering both
// ACATC (incoming credit) and ACATS (outgoing send) activity types.
func (p *Provider) ListACATSTransfers(ctx context.Context, accountID string) ([]*types.ACATSTransfer, error) {
data, _, err := p.do(ctx, http.MethodGet, "/v1/accounts/"+accountID+"/transfers?type=ACATS", 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.ACATSTransfer, 0, len(raw))
for _, r := range raw {
t, err := parseACATSTransfer(r)
if err != nil {
continue
}
transfers = append(transfers, t)
}
return transfers, nil
}
// ListACATSActivities returns account activities of type ACATC and ACATS,
// which represent ACATS credit and send events respectively.
func (p *Provider) ListACATSActivities(ctx context.Context, accountID string) ([]*types.Activity, error) {
path := "/v1/accounts/" + accountID + "/activities?activity_type=ACATC,ACATS"
data, _, err := p.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var raw []json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
activities := make([]*types.Activity, 0, len(raw))
for _, r := range raw {
var a types.Activity
if json.Unmarshal(r, &a) != nil {
continue
}
activities = append(activities, &a)
}
return activities, nil
}
// CancelACATSTransfer cancels a pending ACATS transfer.
func (p *Provider) CancelACATSTransfer(ctx context.Context, accountID, transferID string) error {
_, _, err := p.do(ctx, http.MethodDelete, "/v1/accounts/"+accountID+"/transfers/"+transferID, nil)
return err
}
func parseACATSTransfer(data []byte) (*types.ACATSTransfer, error) {
var raw struct {
ID string `json:"id"`
AccountID string `json:"account_id"`
Direction string `json:"direction"`
Status string `json:"status"`
ContraAccount string `json:"contra_account_number"`
ContraBroker string `json:"contra_broker_number"`
Type string `json:"type"`
TransferType string `json:"transfer_type"`
Assets []struct {
Symbol string `json:"symbol"`
Qty string `json:"qty"`
Status string `json:"status"`
} `json:"assets"`
RejectReason string `json:"reject_reason"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
t := &types.ACATSTransfer{
ID: raw.ID,
AccountID: raw.AccountID,
Direction: raw.Direction,
Status: raw.Status,
ContraAccount: raw.ContraAccount,
ContraBroker: raw.ContraBroker,
Type: raw.Type,
RejectReason: raw.RejectReason,
CreatedAt: raw.CreatedAt,
UpdatedAt: raw.UpdatedAt,
}
// Alpaca may return the ACATS subtype as "transfer_type" instead of "type".
if t.Type == "" && raw.TransferType != "" {
t.Type = raw.TransferType
}
if len(raw.Assets) > 0 {
t.Assets = make([]types.ACATSAsset, len(raw.Assets))
for i, a := range raw.Assets {
t.Assets[i] = types.ACATSAsset{
Symbol: a.Symbol,
Qty: a.Qty,
Status: a.Status,
}
}
}
return t, nil
}
+377
View File
@@ -0,0 +1,377 @@
package alpaca
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/luxfi/broker/pkg/types"
)
func TestACATSDisclosureNotEmpty(t *testing.T) {
if ACATSDisclosure == "" {
t.Fatal("ACATSDisclosure constant must not be empty")
}
if !strings.Contains(ACATSDisclosure, "Alpaca Securities LLC") {
t.Fatal("ACATSDisclosure must mention Alpaca Securities LLC")
}
}
func TestValidateACATSAssets(t *testing.T) {
tests := []struct {
name string
assets []ACATSAsset
wantErr string
}{
{
name: "empty assets",
assets: nil,
wantErr: "at least one asset",
},
{
name: "crypto symbol",
assets: []ACATSAsset{{Symbol: "BTC/USD", Qty: "1"}},
wantErr: "crypto",
},
{
name: "options OCC symbol",
assets: []ACATSAsset{{Symbol: "AAPL260418C00150000", Qty: "1"}},
wantErr: "options",
},
{
name: "fractional shares",
assets: []ACATSAsset{{Symbol: "AAPL", Qty: "1.5"}},
wantErr: "whole shares",
},
{
name: "zero qty",
assets: []ACATSAsset{{Symbol: "AAPL", Qty: "0"}},
wantErr: "positive",
},
{
name: "negative qty",
assets: []ACATSAsset{{Symbol: "AAPL", Qty: "-5"}},
wantErr: "positive",
},
{
name: "invalid qty",
assets: []ACATSAsset{{Symbol: "AAPL", Qty: "abc"}},
wantErr: "positive",
},
{
name: "valid single asset",
assets: []ACATSAsset{{Symbol: "AAPL", Qty: "10"}},
},
{
name: "valid multiple assets",
assets: []ACATSAsset{
{Symbol: "AAPL", Qty: "10"},
{Symbol: "MSFT", Qty: "5"},
{Symbol: "GOOG", Qty: "1"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateACATSAssets(tt.assets)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error %q does not contain %q", err, tt.wantErr)
}
})
}
}
func TestValidateACATSAccount_Active(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/accounts/acct-1": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "acct-1",
"account_number": "123",
"status": "ACTIVE",
"currency": "USD",
})
},
})
err := p.ValidateACATSAccount(context.Background(), "acct-1")
if err != nil {
t.Fatalf("expected no error for ACTIVE account, got: %v", err)
}
}
func TestValidateACATSAccount_Rejected(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/accounts/acct-2": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "acct-2",
"account_number": "456",
"status": "SUBMITTED",
"currency": "USD",
})
},
})
err := p.ValidateACATSAccount(context.Background(), "acct-2")
if err == nil {
t.Fatal("expected error for SUBMITTED account")
}
if !strings.Contains(err.Error(), "not eligible") {
t.Fatalf("error %q does not mention eligibility", err)
}
}
func TestCreateACATSTransfer_Full(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/accounts/acct-1": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "acct-1",
"status": "ACTIVE",
})
},
"/v1/accounts/acct-1/transfers": func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "xfer-1",
"account_id": "acct-1",
"direction": "INCOMING",
"status": "QUEUED",
"type": "ACATS",
"transfer_type": "FULL",
"contra_account_number": "999-888",
"contra_broker_number": "0123",
"created_at": "2026-04-05T10:00:00Z",
"updated_at": "2026-04-05T10:00:00Z",
})
},
})
transfer, err := p.CreateACATSTransfer(context.Background(), "acct-1", &types.CreateACATSTransferRequest{
ContraAccount: "999-888",
ContraBroker: "0123",
Type: "FULL",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if transfer.ID != "xfer-1" {
t.Fatalf("ID = %q, want xfer-1", transfer.ID)
}
if transfer.Status != "QUEUED" {
t.Fatalf("Status = %q, want QUEUED", transfer.Status)
}
if transfer.Type != "ACATS" {
t.Fatalf("Type = %q, want ACATS", transfer.Type)
}
}
func TestCreateACATSTransfer_PartialValidationFails(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/accounts/acct-1": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "acct-1",
"status": "ACTIVE",
})
},
})
_, err := p.CreateACATSTransfer(context.Background(), "acct-1", &types.CreateACATSTransferRequest{
ContraAccount: "999-888",
ContraBroker: "0123",
Type: "PARTIAL",
Assets: []types.ACATSAsset{{Symbol: "BTC/USD", Qty: "1"}},
})
if err == nil {
t.Fatal("expected error for crypto asset in ACATS")
}
if !strings.Contains(err.Error(), "crypto") {
t.Fatalf("error %q does not mention crypto", err)
}
}
func TestGetACATSTransfer(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/accounts/acct-1/transfers/xfer-1": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "xfer-1",
"account_id": "acct-1",
"status": "APPROVED",
"direction": "INCOMING",
"type": "ACATS",
"reject_reason": "",
"created_at": "2026-04-05T10:00:00Z",
"updated_at": "2026-04-05T10:30:00Z",
})
},
})
transfer, err := p.GetACATSTransfer(context.Background(), "acct-1", "xfer-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if transfer.Status != "APPROVED" {
t.Fatalf("Status = %q, want APPROVED", transfer.Status)
}
}
func TestGetACATSRejection(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/accounts/acct-1/transfers/xfer-rej": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "xfer-rej",
"account_id": "acct-1",
"status": "REJECTED",
"direction": "INCOMING",
"type": "ACATS",
"reject_reason": "Account number mismatch at contra broker",
"created_at": "2026-04-05T10:00:00Z",
"updated_at": "2026-04-05T11:00:00Z",
})
},
})
reason, err := p.GetACATSRejection(context.Background(), "acct-1", "xfer-rej")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if reason != "Account number mismatch at contra broker" {
t.Fatalf("reason = %q, want account number mismatch", reason)
}
}
func TestGetACATSRejection_NotRejected(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/accounts/acct-1/transfers/xfer-ok": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "xfer-ok",
"account_id": "acct-1",
"status": "APPROVED",
"direction": "INCOMING",
"type": "ACATS",
"created_at": "2026-04-05T10:00:00Z",
"updated_at": "2026-04-05T10:30:00Z",
})
},
})
_, err := p.GetACATSRejection(context.Background(), "acct-1", "xfer-ok")
if err == nil {
t.Fatal("expected error for non-rejected transfer")
}
if !strings.Contains(err.Error(), "not rejected") {
t.Fatalf("error %q does not mention 'not rejected'", err)
}
}
func TestListACATSTransfers(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/accounts/acct-1/transfers": func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("type") != "ACATS" {
http.Error(w, "missing type=ACATS", http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode([]map[string]interface{}{
{"id": "xfer-1", "status": "COMPLETED", "type": "ACATS", "created_at": "2026-04-01T00:00:00Z", "updated_at": "2026-04-03T00:00:00Z"},
{"id": "xfer-2", "status": "QUEUED", "type": "ACATS", "created_at": "2026-04-05T00:00:00Z", "updated_at": "2026-04-05T00:00:00Z"},
})
},
})
transfers, err := p.ListACATSTransfers(context.Background(), "acct-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(transfers) != 2 {
t.Fatalf("got %d transfers, want 2", len(transfers))
}
if transfers[0].ID != "xfer-1" || transfers[1].ID != "xfer-2" {
t.Fatalf("unexpected transfer IDs: %s, %s", transfers[0].ID, transfers[1].ID)
}
}
func TestListACATSActivities(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/accounts/acct-1/activities": func(w http.ResponseWriter, r *http.Request) {
actType := r.URL.Query().Get("activity_type")
if !strings.Contains(actType, "ACATC") || !strings.Contains(actType, "ACATS") {
http.Error(w, "expected activity_type=ACATC,ACATS", http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode([]map[string]interface{}{
{"id": "act-1", "activity_type": "ACATC", "account_id": "acct-1", "date": "2026-04-03"},
{"id": "act-2", "activity_type": "ACATS", "account_id": "acct-1", "date": "2026-04-05"},
})
},
})
activities, err := p.ListACATSActivities(context.Background(), "acct-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(activities) != 2 {
t.Fatalf("got %d activities, want 2", len(activities))
}
if activities[0].ActivityType != "ACATC" {
t.Fatalf("first activity type = %q, want ACATC", activities[0].ActivityType)
}
if activities[1].ActivityType != "ACATS" {
t.Fatalf("second activity type = %q, want ACATS", activities[1].ActivityType)
}
}
func TestCancelACATSTransfer(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/accounts/acct-1/transfers/xfer-1": func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusNoContent)
},
})
err := p.CancelACATSTransfer(context.Background(), "acct-1", "xfer-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestParseACATSTransfer_TransferTypeFallback(t *testing.T) {
data := `{
"id": "xfer-3",
"account_id": "acct-1",
"status": "QUEUED",
"direction": "INCOMING",
"type": "",
"transfer_type": "PARTIAL",
"assets": [{"symbol": "AAPL", "qty": "10", "status": "QUEUED"}],
"created_at": "2026-04-05T10:00:00Z",
"updated_at": "2026-04-05T10:00:00Z"
}`
transfer, err := parseACATSTransfer([]byte(data))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if transfer.Type != "PARTIAL" {
t.Fatalf("Type = %q, want PARTIAL (from transfer_type fallback)", transfer.Type)
}
if len(transfer.Assets) != 1 {
t.Fatalf("got %d assets, want 1", len(transfer.Assets))
}
if transfer.Assets[0].Symbol != "AAPL" {
t.Fatalf("asset symbol = %q, want AAPL", transfer.Assets[0].Symbol)
}
}
+29
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"strings"
"time"
"github.com/luxfi/broker/pkg/types"
)
@@ -153,3 +154,31 @@ func (p *Provider) GetAccountActivities(ctx context.Context, accountID string, p
}
return activities, nil
}
// AddCryptoAgreement PATCHes a crypto_agreement onto an existing account.
func (p *Provider) AddCryptoAgreement(ctx context.Context, accountID string) error {
body := map[string]interface{}{
"agreements": []map[string]interface{}{
{
"agreement": "crypto_agreement",
"signed_at": time.Now().UTC().Format(time.RFC3339),
"ip_address": "0.0.0.0",
},
},
}
_, _, 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-1", 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
}
+31
View File
@@ -173,6 +173,37 @@ func (p *Provider) GetCryptoTrades(ctx context.Context, req *types.CryptoTradesR
return resp, nil
}
// ListCryptoAssets returns all active crypto assets with their specific fields.
func (p *Provider) ListCryptoAssets(ctx context.Context) ([]*types.Asset, error) {
return p.ListAssets(ctx, "crypto")
}
// ListCryptoWallets lists crypto wallets for a given account.
func (p *Provider) ListCryptoWallets(ctx context.Context, accountID string) ([]types.CryptoWallet, error) {
data, _, err := p.do(ctx, http.MethodGet, "/v1/accounts/"+accountID+"/wallets/crypto", nil)
if err != nil {
return nil, err
}
var wallets []types.CryptoWallet
if err := json.Unmarshal(data, &wallets); err != nil {
return nil, err
}
return wallets, nil
}
// GetCryptoWallet returns a single crypto wallet by ID.
func (p *Provider) GetCryptoWallet(ctx context.Context, accountID, walletID string) (*types.CryptoWallet, error) {
data, _, err := p.do(ctx, http.MethodGet, "/v1/accounts/"+accountID+"/wallets/crypto/"+walletID, nil)
if err != nil {
return nil, err
}
var w types.CryptoWallet
if err := json.Unmarshal(data, &w); err != nil {
return nil, err
}
return &w, nil
}
func (p *Provider) GetCryptoSnapshots(ctx context.Context, symbols []string) (map[string]*types.CryptoSnapshot, error) {
path := "/v1beta3/crypto/us/snapshots?symbols=" + strings.Join(symbols, ",")
data, _, err := p.doData(ctx, http.MethodGet, path)
+27
View File
@@ -78,6 +78,33 @@ func (p *Provider) DownloadDocument(ctx context.Context, accountID, documentID s
return data, "application/pdf", nil
}
// GetSubaccountConfirms returns trade confirmations and statements for a subaccount.
func (p *Provider) GetSubaccountConfirms(ctx context.Context, accountID string, params map[string]string) ([]types.Document, error) {
path := "/v1/accounts/" + accountID + "/documents?type=trade_confirmation"
for k, v := range params {
path += "&" + k + "=" + v
}
data, _, err := p.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var raw []json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
docs := make([]types.Document, 0, len(raw))
for _, r := range raw {
d, err := p.parseDocument(r)
if err != nil {
continue
}
docs = append(docs, *d)
}
return docs, nil
}
func (p *Provider) parseDocument(data []byte) (*types.Document, error) {
var raw struct {
ID string `json:"id"`
+10 -8
View File
@@ -81,20 +81,22 @@ func (p *Provider) StreamAccountEvents(ctx context.Context, since string) (<-cha
ch := make(chan *types.AccountEvent, 64)
err := p.streamSSE(ctx, path, func(data []byte) {
var raw struct {
EventType string `json:"event"`
EventID string `json:"event_id"`
AccountID string `json:"account_id"`
Timestamp string `json:"at"`
EventType string `json:"event"`
EventID string `json:"event_id"`
AccountID string `json:"account_id"`
TradingBlocked bool `json:"trading_blocked"`
Timestamp string `json:"at"`
}
if json.Unmarshal(data, &raw) != nil {
return
}
select {
case ch <- &types.AccountEvent{
EventType: raw.EventType,
EventID: raw.EventID,
AccountID: raw.AccountID,
Timestamp: raw.Timestamp,
EventType: raw.EventType,
EventID: raw.EventID,
AccountID: raw.AccountID,
TradingBlocked: raw.TradingBlocked,
Timestamp: raw.Timestamp,
}:
default:
}
+216
View File
@@ -0,0 +1,216 @@
package alpaca
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/luxfi/broker/pkg/types"
)
// --- Fixed Income: Asset Retrieval (Checklist #1) ---
// ListCorporateBonds returns US corporate bond assets.
// Alpaca API: GET /v1/assets/fixed_income/us_corporates
func (p *Provider) ListCorporateBonds(ctx context.Context) ([]*types.Asset, error) {
return p.listFixedIncomeAssets(ctx, "/v1/assets/fixed_income/us_corporates")
}
// ListTreasuryBonds returns US treasury bond assets.
// Alpaca API: GET /v1/assets/fixed_income/us_treasuries
func (p *Provider) ListTreasuryBonds(ctx context.Context) ([]*types.Asset, error) {
return p.listFixedIncomeAssets(ctx, "/v1/assets/fixed_income/us_treasuries")
}
func (p *Provider) listFixedIncomeAssets(ctx context.Context, path string) ([]*types.Asset, error) {
data, _, err := p.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, fmt.Errorf("list fixed income assets: %w", err)
}
// Alpaca FI endpoint returns different fields than equities.
// Parse what the API provides and map to the unified Asset type.
var raw []struct {
ID string `json:"id"`
CUSIP string `json:"cusip"`
Symbol string `json:"symbol"`
Name string `json:"name"`
Status string `json:"status"`
Tradable bool `json:"tradable"`
Fractionable bool `json:"fractionable"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("decode fixed income assets: %w", err)
}
assets := make([]*types.Asset, len(raw))
for i, r := range raw {
symbol := r.Symbol
if symbol == "" {
symbol = r.CUSIP
}
assets[i] = &types.Asset{
ID: r.ID,
Provider: "alpaca",
Symbol: symbol,
Name: r.Name,
Class: "fixed_income",
Status: r.Status,
Tradable: r.Tradable,
Fractionable: r.Fractionable,
}
}
return assets, nil
}
// --- Fixed Income: Order Execution (Checklist #2, #3, #4, #5) ---
// CreateFixedIncomeOrder places a fixed income order.
//
// Constraints enforced:
// - TIF forced to "day" (#4)
// - Only market orders for PILOT (internally limit, but client sees market)
// - Notional validated to 2 decimal places, qty to 9 decimal places (#2, #3)
// - Qty represents par value ($1000 per bond), recommend 9dp display (#11)
// - Extended hours supported for sell orders (#5: order submitted out of hours pends)
func (p *Provider) CreateFixedIncomeOrder(ctx context.Context, accountID string, req *types.CreateOrderRequest) (*types.Order, error) {
if req.Symbol == "" {
return nil, fmt.Errorf("fixed income order: symbol (CUSIP) is required")
}
side := strings.ToLower(req.Side)
if side != "buy" && side != "sell" {
return nil, fmt.Errorf("fixed income order: side must be buy or sell, got %q", req.Side)
}
// Only market orders for fixed income PILOT.
if req.Type != "" && strings.ToLower(req.Type) != "market" {
return nil, fmt.Errorf("fixed income order: only market orders supported, got %q", req.Type)
}
// TIF forced to day for fixed income.
if req.TimeInForce != "" && strings.ToLower(req.TimeInForce) != "day" {
return nil, fmt.Errorf("fixed income order: only day TIF supported, got %q", req.TimeInForce)
}
// Decimal precision validation using the existing helper.
if err := validateDecimalPlaces(req.Notional, 2, "notional"); err != nil {
return nil, err
}
if err := validateDecimalPlaces(req.Qty, 9, "quantity"); err != nil {
return nil, err
}
body := map[string]interface{}{
"symbol": req.Symbol,
"side": req.Side,
"type": "market",
"time_in_force": "day",
}
if req.Qty != "" {
body["qty"] = req.Qty
}
if req.Notional != "" {
body["notional"] = req.Notional
}
if req.LimitPrice != "" {
body["limit_price"] = req.LimitPrice
}
if req.ClientOrderID != "" {
body["client_order_id"] = req.ClientOrderID
}
// Extended hours for sell orders (#3, #5).
// Out-of-hours orders will be accepted and pend until market open.
if req.ExtendedHours && side == "sell" {
body["extended_hours"] = true
}
data, _, err := p.do(ctx, http.MethodPost, "/v1/trading/accounts/"+accountID+"/orders", body)
if err != nil {
return nil, fmt.Errorf("create fixed income order: %w", err)
}
order, err := p.parseOrder(data)
if err != nil {
return nil, err
}
order.AssetClass = "fixed_income"
return order, nil
}
// --- Fixed Income: Cancel Order (Checklist #7) ---
// CancelFixedIncomeOrder cancels a pending fixed income order.
// Required for limit orders or orders submitted outside market hours.
func (p *Provider) CancelFixedIncomeOrder(ctx context.Context, accountID, orderID string) error {
return p.CancelOrder(ctx, accountID, orderID)
}
// --- Fixed Income: Order Status (Checklist #9) ---
// GetFixedIncomeOrder returns the status of a fixed income order.
// FI orders may have statuses: new, pending_new, accepted, filled, canceled, expired.
func (p *Provider) GetFixedIncomeOrder(ctx context.Context, accountID, orderID string) (*types.Order, error) {
order, err := p.GetOrder(ctx, accountID, orderID)
if err != nil {
return nil, err
}
order.AssetClass = "fixed_income"
return order, nil
}
// --- Fixed Income: Account Activities (Checklist #10) ---
// GetFixedIncomeActivities returns account activities filtered to maturity corporate
// action events only (Pilot constraint). Other FI activity types are excluded.
func (p *Provider) GetFixedIncomeActivities(ctx context.Context, accountID string) ([]*types.Activity, error) {
return p.GetAccountActivities(ctx, accountID, &types.ActivityParams{
ActivityTypes: []string{"maturity"},
})
}
// --- Fixed Income: Positions (Checklist #11, #12) ---
// GetFixedIncomePositions returns fixed income positions for an account.
// Note: qty represents par value ($1000 per bond), not the number of bonds.
// Display recommendation: 9 decimal places for qty.
func (p *Provider) GetFixedIncomePositions(ctx context.Context, accountID string) ([]*types.Position, error) {
data, _, err := p.do(ctx, http.MethodGet, "/v1/trading/accounts/"+accountID+"/positions", nil)
if err != nil {
return nil, fmt.Errorf("list positions: %w", err)
}
var raw []json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("decode positions: %w", err)
}
var positions []*types.Position
for _, r := range raw {
pos, err := p.parsePosition(r)
if err != nil {
continue
}
if pos.AssetClass == "fixed_income" {
positions = append(positions, pos)
}
}
return positions, nil
}
// CloseFixedIncomePosition closes an open fixed income position.
func (p *Provider) CloseFixedIncomePosition(ctx context.Context, accountID, symbol string, qty *float64) (*types.Order, error) {
order, err := p.ClosePosition(ctx, accountID, symbol, qty)
if err != nil {
return nil, fmt.Errorf("close fixed income position: %w", err)
}
order.AssetClass = "fixed_income"
return order, nil
}
// --- Fixed Income: Market Data (Checklist #13) ---
// Market data for fixed income requires the Moment Terms of Service to be signed
// before enabling. See Alpaca docs for Moment ToS agreement flow.
// Once enabled, use the standard data API endpoints with fixed income symbols.
@@ -0,0 +1,358 @@
package alpaca
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/luxfi/broker/pkg/types"
)
func TestListCorporateBonds(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"GET /v1/assets/fixed_income/us_corporates": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": "bond-001",
"cusip": "037833100",
"symbol": "AAPL4.5-2030",
"name": "Apple Inc 4.5% 2030",
"status": "active",
"tradable": true,
"fractionable": false,
},
{
"id": "bond-002",
"cusip": "594918104",
"name": "Microsoft Corp 3.0% 2028",
"status": "active",
"tradable": true,
"fractionable": false,
},
})
},
})
assets, err := p.ListCorporateBonds(context.Background())
if err != nil {
t.Fatalf("ListCorporateBonds: %v", err)
}
if len(assets) != 2 {
t.Fatalf("expected 2 assets, got %d", len(assets))
}
// First asset has explicit symbol
if assets[0].Symbol != "AAPL4.5-2030" {
t.Errorf("asset[0].Symbol = %q, want AAPL4.5-2030", assets[0].Symbol)
}
if assets[0].Class != "fixed_income" {
t.Errorf("asset[0].Class = %q, want fixed_income", assets[0].Class)
}
if !assets[0].Tradable {
t.Error("asset[0].Tradable = false, want true")
}
// Second asset falls back to CUSIP as symbol
if assets[1].Symbol != "594918104" {
t.Errorf("asset[1].Symbol = %q, want 594918104 (CUSIP fallback)", assets[1].Symbol)
}
}
func TestListTreasuryBonds(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"GET /v1/assets/fixed_income/us_treasuries": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": "treas-001",
"cusip": "912828ZT6",
"name": "US Treasury 2.5% 2027",
"status": "active",
"tradable": true,
},
})
},
})
assets, err := p.ListTreasuryBonds(context.Background())
if err != nil {
t.Fatalf("ListTreasuryBonds: %v", err)
}
if len(assets) != 1 {
t.Fatalf("expected 1 asset, got %d", len(assets))
}
if assets[0].Symbol != "912828ZT6" {
t.Errorf("symbol = %q, want 912828ZT6", assets[0].Symbol)
}
if assets[0].Class != "fixed_income" {
t.Errorf("class = %q, want fixed_income", assets[0].Class)
}
}
func TestCreateFixedIncomeOrder_Buy(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"POST /v1/trading/accounts/acc-1/orders": func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
if body["type"] != "market" {
t.Errorf("order type = %v, want market", body["type"])
}
if body["time_in_force"] != "day" {
t.Errorf("TIF = %v, want day", body["time_in_force"])
}
if _, ok := body["extended_hours"]; ok {
t.Error("extended_hours should not be set for buy orders")
}
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "order-fi-1",
"symbol": "037833100",
"side": "buy",
"type": "market",
"time_in_force": "day",
"status": "accepted",
"qty": "5000",
"asset_class": "us_equity",
"created_at": "2026-04-05T10:00:00Z",
})
},
})
order, err := p.CreateFixedIncomeOrder(context.Background(), "acc-1", &types.CreateOrderRequest{
Symbol: "037833100",
Side: "buy",
Type: "market",
TimeInForce: "day",
Qty: "5000",
})
if err != nil {
t.Fatalf("CreateFixedIncomeOrder: %v", err)
}
if order.AssetClass != "fixed_income" {
t.Errorf("AssetClass = %q, want fixed_income", order.AssetClass)
}
if order.Status != "accepted" {
t.Errorf("Status = %q, want accepted", order.Status)
}
}
func TestCreateFixedIncomeOrder_SellExtendedHours(t *testing.T) {
var gotExtended bool
_, p := testServer(t, map[string]http.HandlerFunc{
"POST /v1/trading/accounts/acc-1/orders": func(w http.ResponseWriter, r *http.Request) {
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
if eh, ok := body["extended_hours"]; ok && eh == true {
gotExtended = true
}
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "order-fi-2",
"symbol": "037833100",
"side": "sell",
"type": "market",
"time_in_force": "day",
"status": "pending_new",
"qty": "2000",
"created_at": "2026-04-05T20:00:00Z",
})
},
})
order, err := p.CreateFixedIncomeOrder(context.Background(), "acc-1", &types.CreateOrderRequest{
Symbol: "037833100",
Side: "sell",
Qty: "2000",
ExtendedHours: true,
})
if err != nil {
t.Fatalf("CreateFixedIncomeOrder sell: %v", err)
}
if !gotExtended {
t.Error("extended_hours not sent for sell order with ExtendedHours=true")
}
if order.Status != "pending_new" {
t.Errorf("Status = %q, want pending_new (out-of-hours)", order.Status)
}
}
func TestCreateFixedIncomeOrder_ValidationErrors(t *testing.T) {
p := New(Config{BaseURL: "http://localhost:1", APIKey: "k", APISecret: "s"})
tests := []struct {
name string
req *types.CreateOrderRequest
}{
{"missing symbol", &types.CreateOrderRequest{Side: "buy", Qty: "1000"}},
{"bad side", &types.CreateOrderRequest{Symbol: "X", Side: "short", Qty: "1000"}},
{"non-market type", &types.CreateOrderRequest{Symbol: "X", Side: "buy", Type: "limit", Qty: "1000"}},
{"non-day TIF", &types.CreateOrderRequest{Symbol: "X", Side: "buy", TimeInForce: "gtc", Qty: "1000"}},
{"notional too many decimals", &types.CreateOrderRequest{Symbol: "X", Side: "buy", Notional: "100.123"}},
{"qty too many decimals", &types.CreateOrderRequest{Symbol: "X", Side: "buy", Qty: "1000.1234567890"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := p.CreateFixedIncomeOrder(context.Background(), "acc-1", tt.req)
if err == nil {
t.Fatal("expected validation error, got nil")
}
})
}
}
func TestGetFixedIncomeActivities_MaturityFilter(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"GET /v1/accounts/acc-1/activities": func(w http.ResponseWriter, r *http.Request) {
got := r.URL.Query().Get("activity_type")
if got != "maturity" {
t.Errorf("activity_type filter = %q, want maturity", got)
}
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"id": "act-1",
"account_id": "acc-1",
"activity_type": "maturity",
"symbol": "037833100",
"net_amount": "5000.00",
"date": "2026-04-01",
},
})
},
})
activities, err := p.GetFixedIncomeActivities(context.Background(), "acc-1")
if err != nil {
t.Fatalf("GetFixedIncomeActivities: %v", err)
}
if len(activities) != 1 {
t.Fatalf("expected 1 activity, got %d", len(activities))
}
if activities[0].ActivityType != "maturity" {
t.Errorf("ActivityType = %q, want maturity", activities[0].ActivityType)
}
}
func TestGetFixedIncomePositions_FiltersFixedIncome(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"GET /v1/trading/accounts/acc-1/positions": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]map[string]interface{}{
{
"symbol": "AAPL",
"qty": "10",
"avg_entry_price": "150.00",
"market_value": "1500.00",
"current_price": "155.00",
"unrealized_pl": "50.00",
"side": "long",
"asset_class": "us_equity",
},
{
"symbol": "037833100",
"qty": "5000.000000000",
"avg_entry_price": "98.50",
"market_value": "4925.00",
"current_price": "99.00",
"unrealized_pl": "25.00",
"side": "long",
"asset_class": "fixed_income",
},
})
},
})
positions, err := p.GetFixedIncomePositions(context.Background(), "acc-1")
if err != nil {
t.Fatalf("GetFixedIncomePositions: %v", err)
}
if len(positions) != 1 {
t.Fatalf("expected 1 FI position (filtered), got %d", len(positions))
}
if positions[0].Symbol != "037833100" {
t.Errorf("Symbol = %q, want 037833100", positions[0].Symbol)
}
if positions[0].AssetClass != "fixed_income" {
t.Errorf("AssetClass = %q, want fixed_income", positions[0].AssetClass)
}
// qty is par value ($1000/bond), displayed with 9dp
if positions[0].Qty != "5000.000000000" {
t.Errorf("Qty = %q, want 5000.000000000 (par value, 9dp)", positions[0].Qty)
}
}
func TestCloseFixedIncomePosition(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"DELETE /v1/trading/accounts/acc-1/positions/037833100": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "order-close-1",
"symbol": "037833100",
"side": "sell",
"type": "market",
"time_in_force": "day",
"status": "accepted",
"created_at": "2026-04-05T10:00:00Z",
})
},
})
order, err := p.CloseFixedIncomePosition(context.Background(), "acc-1", "037833100", nil)
if err != nil {
t.Fatalf("CloseFixedIncomePosition: %v", err)
}
if order.AssetClass != "fixed_income" {
t.Errorf("AssetClass = %q, want fixed_income", order.AssetClass)
}
if order.Status != "accepted" {
t.Errorf("Status = %q, want accepted", order.Status)
}
}
func TestGetFixedIncomeOrder(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"GET /v1/trading/accounts/acc-1/orders/order-fi-1": func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "order-fi-1",
"symbol": "037833100",
"side": "buy",
"type": "market",
"time_in_force": "day",
"status": "filled",
"filled_qty": "5000",
"qty": "5000",
"created_at": "2026-04-05T10:00:00Z",
"filled_at": "2026-04-05T10:00:01Z",
})
},
})
order, err := p.GetFixedIncomeOrder(context.Background(), "acc-1", "order-fi-1")
if err != nil {
t.Fatalf("GetFixedIncomeOrder: %v", err)
}
if order.AssetClass != "fixed_income" {
t.Errorf("AssetClass = %q, want fixed_income", order.AssetClass)
}
if order.Status != "filled" {
t.Errorf("Status = %q, want filled", order.Status)
}
}
func TestCancelFixedIncomeOrder(t *testing.T) {
var called bool
_, p := testServer(t, map[string]http.HandlerFunc{
"DELETE /v1/trading/accounts/acc-1/orders/order-fi-1": func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
},
})
err := p.CancelFixedIncomeOrder(context.Background(), "acc-1", "order-fi-1")
if err != nil {
t.Fatalf("CancelFixedIncomeOrder: %v", err)
}
if !called {
t.Error("cancel endpoint was not called")
}
}
+8
View File
@@ -53,6 +53,14 @@ type TransferExtended interface {
DeleteRecipientBank(ctx context.Context, accountID, bankID string) error
}
// ACATSManager is an optional interface for ACATS (Automated Customer Account Transfer Service) operations.
type ACATSManager interface {
CreateACATSTransfer(ctx context.Context, accountID string, req *types.CreateACATSTransferRequest) (*types.ACATSTransfer, error)
GetACATSTransfer(ctx context.Context, accountID, transferID string) (*types.ACATSTransfer, error)
ListACATSTransfers(ctx context.Context, accountID string) ([]*types.ACATSTransfer, error)
CancelACATSTransfer(ctx context.Context, accountID, transferID string) error
}
// CryptoDataProvider is an optional interface for crypto-specific market data.
type CryptoDataProvider interface {
GetCryptoBars(ctx context.Context, req *types.CryptoBarsRequest) (*types.BarsResponse, error)
+67 -12
View File
@@ -1,6 +1,9 @@
package types
import "time"
import (
"fmt"
"time"
)
// Account is the unified brokerage account across all providers.
type Account struct {
@@ -42,12 +45,13 @@ type Contact struct {
// 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"`
AccountID string `json:"account_id"`
Cash string `json:"cash"`
Equity string `json:"equity"`
BuyingPower string `json:"buying_power"`
NonMarginableBuyingPower string `json:"non_marginable_buying_power,omitempty"`
PortfolioValue string `json:"portfolio_value"`
Positions []Position `json:"positions"`
}
// Position is a single holding.
@@ -591,11 +595,12 @@ type TradeEvent struct {
// AccountEvent is emitted when an account status changes.
type AccountEvent struct {
EventType string `json:"event_type"` // ACCOUNT_UPDATED, ACCOUNT_APPROVED, etc.
EventID string `json:"event_id"`
AccountID string `json:"account_id"`
Account *Account `json:"account,omitempty"`
Timestamp string `json:"timestamp"`
EventType string `json:"event_type"` // ACCOUNT_UPDATED, ACCOUNT_APPROVED, etc.
EventID string `json:"event_id"`
AccountID string `json:"account_id"`
Account *Account `json:"account,omitempty"`
TradingBlocked bool `json:"trading_blocked,omitempty"`
Timestamp string `json:"timestamp"`
}
// TransferEvent is emitted when a transfer changes state.
@@ -1042,6 +1047,56 @@ type CreateACATSTransferRequest struct {
Assets []ACATSAsset `json:"assets,omitempty"`
}
// --- Crypto Wallet Types ---
// CryptoWallet is an Alpaca-managed crypto wallet for a brokerage account.
type CryptoWallet struct {
ID string `json:"id"`
AccountID string `json:"account_id"`
Asset string `json:"asset"`
Address string `json:"address"`
Status string `json:"status"`
CreatedAt string `json:"created_at,omitempty"`
}
// --- Country / Regional Reference Types ---
// CountryInfo describes a country's eligibility and restrictions for brokerage.
type CountryInfo struct {
Code string `json:"code"`
Name string `json:"name"`
IsSupported bool `json:"is_supported"`
CryptoAllowed bool `json:"crypto_allowed,omitempty"`
}
// --- TIF Validation ---
// ValidEquityTIF is the set of valid time-in-force values for equity orders.
var ValidEquityTIF = map[string]bool{
"day": true, "gtc": true, "opg": true,
"cls": true, "ioc": true, "fok": true,
}
// ValidCryptoTIF is the set of valid time-in-force values for crypto orders.
var ValidCryptoTIF = map[string]bool{
"gtc": true, "ioc": true,
}
// ValidateTIF returns an error if tif is not valid for the given asset class.
func ValidateTIF(assetClass, tif string) error {
switch assetClass {
case "crypto":
if !ValidCryptoTIF[tif] {
return fmt.Errorf("invalid TIF %q for crypto; allowed: gtc, ioc", tif)
}
default:
if !ValidEquityTIF[tif] {
return fmt.Errorf("invalid TIF %q for %s; allowed: day, gtc, opg, cls, ioc, fok", tif, assetClass)
}
}
return nil
}
// MarginCheckRequest checks margin for a proposed order.
type MarginCheckRequest struct {
Symbol string `json:"symbol"`