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