mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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:
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user