unify FI assets into ListAssets — one endpoint for all asset classes

ListAssets now fetches Alpaca FI treasuries + corporates alongside
equities and crypto. Maps CUSIP as symbol, bond_status as tradable
filter. Corporates 403 (not subscribed) silently skipped.
ATS registerMarketsFromBroker picks them up automatically.
This commit is contained in:
2026-04-16 21:07:02 -07:00
parent 582c834a39
commit 97ba736ff0
2 changed files with 202 additions and 39 deletions
+97 -39
View File
@@ -548,50 +548,108 @@ func (p *Provider) GetCalendar(ctx context.Context, start, end string) ([]*types
// --- Assets (same Alpaca API, different provider tag) ---
func (p *Provider) ListAssets(ctx context.Context, class string) ([]*types.Asset, error) {
path := "/v1/assets?status=active"
if class != "" {
path += "&asset_class=" + class
}
data, _, err := p.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var raw []struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
Name string `json:"name"`
Class string `json:"class"`
Exchange string `json:"exchange"`
Status string `json:"status"`
Tradable bool `json:"tradable"`
Fractionable bool `json:"fractionable"`
MinOrderSize string `json:"min_order_size"`
PriceIncrement string `json:"price_increment"`
MinTradeIncrement string `json:"min_trade_increment"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
assets := make([]*types.Asset, len(raw))
for i, r := range raw {
assets[i] = &types.Asset{
ID: r.ID,
Provider: "alpaca_omnisub",
Symbol: r.Symbol,
Name: r.Name,
Class: r.Class,
Exchange: r.Exchange,
Status: r.Status,
Tradable: r.Tradable,
Fractionable: r.Fractionable,
MinOrderSize: r.MinOrderSize,
PriceIncrement: r.PriceIncrement,
MinTradeIncrement: r.MinTradeIncrement,
var assets []*types.Asset
// Standard asset listing (equities, crypto, etc.)
if class != "fixed_income" {
path := "/v1/assets?status=active"
if class != "" {
path += "&asset_class=" + class
}
data, _, err := p.do(ctx, http.MethodGet, path, nil)
if err != nil {
return nil, err
}
var raw []struct {
ID string `json:"id"`
Symbol string `json:"symbol"`
Name string `json:"name"`
Class string `json:"class"`
Exchange string `json:"exchange"`
Status string `json:"status"`
Tradable bool `json:"tradable"`
Fractionable bool `json:"fractionable"`
MinOrderSize string `json:"min_order_size"`
PriceIncrement string `json:"price_increment"`
MinTradeIncrement string `json:"min_trade_increment"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
for _, r := range raw {
assets = append(assets, &types.Asset{
ID: r.ID,
Provider: "alpaca_omnisub",
Symbol: r.Symbol,
Name: r.Name,
Class: r.Class,
Exchange: r.Exchange,
Status: r.Status,
Tradable: r.Tradable,
Fractionable: r.Fractionable,
MinOrderSize: r.MinOrderSize,
PriceIncrement: r.PriceIncrement,
MinTradeIncrement: r.MinTradeIncrement,
})
}
}
// Fixed income assets: Alpaca serves these from separate endpoints.
if class == "" || class == "fixed_income" {
assets = append(assets, p.listFIAssets(ctx, "/v1/assets/fixed_income/us_treasuries", "us_treasuries")...)
assets = append(assets, p.listFIAssets(ctx, "/v1/assets/fixed_income/us_corporates", "us_corporates")...)
}
return assets, nil
}
// listFIAssets fetches fixed income assets from one of Alpaca's FI endpoints.
// Returns nil on error (403 if not subscribed, network issues, etc.) — callers
// treat FI as best-effort since corporate bonds require a separate subscription.
func (p *Provider) listFIAssets(ctx context.Context, path, key string) []*types.Asset {
data, status, err := p.do(ctx, http.MethodGet, path, nil)
if err != nil || status >= 400 {
return nil
}
var wrapper map[string]json.RawMessage
if err := json.Unmarshal(data, &wrapper); err != nil {
return nil
}
raw, ok := wrapper[key]
if !ok {
return nil
}
var bonds []struct {
CUSIP string `json:"cusip"`
ISIN string `json:"isin"`
Tradable bool `json:"tradable"`
BondStatus string `json:"bond_status"`
Subtype string `json:"subtype"`
CouponRate string `json:"coupon_rate"`
MaturityDate string `json:"maturity_date"`
}
if err := json.Unmarshal(raw, &bonds); err != nil {
return nil
}
var assets []*types.Asset
for _, b := range bonds {
name := "US Treasury " + b.Subtype
if key == "us_corporates" {
name = "US Corporate " + b.Subtype
}
assets = append(assets, &types.Asset{
ID: b.CUSIP,
Provider: "alpaca_omnisub",
Symbol: b.CUSIP,
Name: name,
Class: "fixed_income",
Status: b.BondStatus,
Tradable: b.BondStatus == "outstanding" && b.Tradable,
})
}
return assets
}
func (p *Provider) GetAsset(ctx context.Context, symbolOrID string) (*types.Asset, error) {
apiSymbol := strings.ReplaceAll(symbolOrID, "/", "")
data, _, err := p.do(ctx, http.MethodGet, "/v1/assets/"+apiSymbol, nil)
@@ -626,6 +626,111 @@ func TestGetSnapshotStock(t *testing.T) {
}
}
func TestListAssets_IncludesFixedIncome(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/assets": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]map[string]interface{}{
{"id": "eq-1", "symbol": "AAPL", "name": "Apple Inc", "class": "us_equity", "status": "active", "tradable": true},
})
},
"/v1/assets/fixed_income/us_treasuries": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"us_treasuries": []map[string]interface{}{
{"cusip": "912797QD2", "isin": "US912797QD26", "tradable": true, "bond_status": "outstanding", "subtype": "bill", "coupon_rate": "0", "maturity_date": "2026-07-09"},
{"cusip": "912797XX9", "isin": "US912797XX99", "tradable": false, "bond_status": "matured", "subtype": "note", "coupon_rate": "2.5", "maturity_date": "2024-01-01"},
},
})
},
"/v1/assets/fixed_income/us_corporates": func(w http.ResponseWriter, r *http.Request) {
// Simulate 403 — not subscribed
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"message":"not subscribed"}`))
},
})
// Fetch all: should include equity + FI (corporates 403 is silently skipped)
assets, err := p.ListAssets(context.Background(), "")
if err != nil {
t.Fatalf("ListAssets error: %v", err)
}
// 1 equity + 1 tradable treasury (matured one has Tradable=false from bond_status != outstanding)
var equities, fi int
for _, a := range assets {
switch a.Class {
case "us_equity":
equities++
case "fixed_income":
fi++
}
}
if equities != 1 {
t.Errorf("equities = %d, want 1", equities)
}
if fi != 2 {
t.Errorf("fixed_income assets = %d, want 2 (both returned, tradable flag varies)", fi)
}
// Verify the outstanding treasury is tradable, the matured one is not
for _, a := range assets {
if a.Symbol == "912797QD2" {
if !a.Tradable {
t.Error("912797QD2 should be tradable (outstanding + tradable=true)")
}
if a.Name != "US Treasury bill" {
t.Errorf("name = %q, want 'US Treasury bill'", a.Name)
}
}
if a.Symbol == "912797XX9" {
if a.Tradable {
t.Error("912797XX9 should not be tradable (matured)")
}
}
}
}
func TestListAssets_FixedIncomeOnly(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/assets/fixed_income/us_treasuries": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"us_treasuries": []map[string]interface{}{
{"cusip": "912797QD2", "tradable": true, "bond_status": "outstanding", "subtype": "bill"},
},
})
},
"/v1/assets/fixed_income/us_corporates": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"us_corporates": []map[string]interface{}{
{"cusip": "037833100", "tradable": true, "bond_status": "outstanding", "subtype": "senior"},
},
})
},
})
// class=fixed_income should NOT call the standard /v1/assets endpoint
assets, err := p.ListAssets(context.Background(), "fixed_income")
if err != nil {
t.Fatalf("ListAssets error: %v", err)
}
if len(assets) != 2 {
t.Fatalf("expected 2 FI assets, got %d", len(assets))
}
for _, a := range assets {
if a.Class != "fixed_income" {
t.Errorf("asset %s class = %q, want 'fixed_income'", a.Symbol, a.Class)
}
}
// Check corporate naming
for _, a := range assets {
if a.Symbol == "037833100" && a.Name != "US Corporate senior" {
t.Errorf("corporate name = %q, want 'US Corporate senior'", a.Name)
}
}
}
func TestErrorHandling(t *testing.T) {
_, p := testServer(t, map[string]http.HandlerFunc{
"/v1/accounts/bad-id": func(w http.ResponseWriter, r *http.Request) {