Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ In typical usage, import both packages: use `providers` to construct a provider,

## Supported Providers

| Provider | Regions |
|-----------|----------------|
| iShares | us, de, uk, fr |
| Amundi | de, uk, fr |
| Xtrackers | de, uk, fr |
| Provider | Regions |
|-----------|--------------------|
| iShares | us, de, uk, fr, ch |
| Amundi | de, uk, fr |
| Xtrackers | de, uk, fr |

## Installation

Expand Down
1 change: 1 addition & 0 deletions enums.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const (
ExchangeLSE Exchange = "LSE" // London Stock Exchange
ExchangeEuronext Exchange = "Euronext" // Euronext
ExchangeXetra Exchange = "Xetra" // Xetra
ExchangeSIX Exchange = "SIX" // SIX Swiss Exchange
ExchangeTSE Exchange = "TSE" // Tokyo Stock Exchange
ExchangeHKEX Exchange = "HKEX" // Hong Kong Exchange
ExchangeSSE Exchange = "SSE" // Shanghai Stock Exchange
Expand Down
2 changes: 1 addition & 1 deletion internal/providers/ishares/client.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package ishares provides a client for fetching iShares ETF data.
//
// The client supports multiple regions (US, DE) and allows configuration
// The client supports multiple regions (US, DE, UK, FR, CH) and allows configuration
// through functional options.
//
// Example usage:
Expand Down
2 changes: 1 addition & 1 deletion internal/providers/ishares/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func TestNew(t *testing.T) {
})

t.Run("supported regions", func(t *testing.T) {
regions := []string{"us", "de", "US", "DE"}
regions := []string{"us", "de", "uk", "fr", "ch", "US", "DE", "UK", "FR", "CH"}

for _, region := range regions {
_, err := New(region)
Expand Down
2 changes: 2 additions & 0 deletions internal/providers/ishares/column_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ func (r *columnResolver) normalizeNumber(s string) string {
s = strings.ReplaceAll(s, "$", "")
s = strings.ReplaceAll(s, "€", "")
s = strings.ReplaceAll(s, "'", "") // Swiss thousands separator
s = strings.ReplaceAll(s, "’", "") // Swiss thousands separator (curly apostrophe)
s = strings.ReplaceAll(s, "‘", "") // Swiss thousands separator (curly apostrophe)
Comment thread
yevklym marked this conversation as resolved.

lastComma := strings.LastIndex(s, ",")
lastDot := strings.LastIndex(s, ".")
Expand Down
10 changes: 10 additions & 0 deletions internal/providers/ishares/column_resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,16 @@ func TestColumnResolver_NormalizeNumber(t *testing.T) {
input: "1'234'567.89",
want: "1234567.89",
},
{
name: "Swiss format with curly apostrophe",
input: "1’234’567.89",
want: "1234567.89",
},
{
name: "Swiss format with left curly apostrophe",
input: "1‘234‘567.89",
want: "1234567.89",
},
{
name: "with dollar sign",
input: "$1,234.56",
Expand Down
77 changes: 77 additions & 0 deletions internal/providers/ishares/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,83 @@ var regionConfigs = map[string]regionConfig{
MarketCurrency: []string{"Market Currency"},
},
},
"ch": {
DiscoveryURL: "https://www.ishares.com/ch/individual/en/product-screener/product-screener-v3.1.jsn?dcrPath=/templatedata/config/product-screener-v3/data/en/ch/product-screener/ishares-product-screener-backend-config&siteEntryPassthrough=true",
BaseURL: "https://www.ishares.com",
HoldingsURLTemplate: "%s%s/1495092304805.ajax?fileType=csv",
DefaultCurrency: etfscraper.CurrencyCHF,
DefaultExchange: etfscraper.ExchangeSIX,
AssetClassMapping: map[string]etfscraper.AssetClass{
"equity": etfscraper.AssetClassEquity,
"fixed income": etfscraper.AssetClassBond,
"cash": etfscraper.AssetClassCash,
"commodity": etfscraper.AssetClassCommodity,
"real estate": etfscraper.AssetClassRealEstate,
"digital assets": etfscraper.AssetClassCryptocurrency,
"multi asset": etfscraper.AssetClassAlternative,
},
SectorMapping: map[string]etfscraper.Sector{
"energy": etfscraper.SectorEnergy,
"materials": etfscraper.SectorMaterials,
"industrials": etfscraper.SectorIndustrials,
"consumer discretionary": etfscraper.SectorConsumerDiscretionary,
"consumer staples": etfscraper.SectorConsumerStaples,
"health care": etfscraper.SectorHealthcare,
"financials": etfscraper.SectorFinancials,
"information technology": etfscraper.SectorInformationTechnology,
"communication": etfscraper.SectorTelecommunication,
"utilities": etfscraper.SectorUtilities,
"real estate": etfscraper.SectorRealEstate,
},
LocationMapping: map[string]etfscraper.Location{
"united states": etfscraper.LocationUnitedStates,
"united kingdom": etfscraper.LocationUnitedKingdom,
"japan": etfscraper.LocationJapan,
"germany": etfscraper.LocationGermany,
"france": etfscraper.LocationFrance,
"switzerland": etfscraper.LocationSwitzerland,
"canada": etfscraper.LocationCanada,
"australia": etfscraper.LocationAustralia,
"china": etfscraper.LocationChina,
"taiwan": etfscraper.LocationTaiwan,
"south korea": etfscraper.LocationSouthKorea,
"india": etfscraper.LocationIndia,
"brazil": etfscraper.LocationBrazil,
"netherlands": etfscraper.LocationNetherlands,
"sweden": etfscraper.LocationSweden,
"italy": etfscraper.LocationItaly,
"spain": etfscraper.LocationSpain,
"ireland": etfscraper.LocationIreland,
"denmark": etfscraper.LocationDenmark,
"finland": etfscraper.LocationFinland,
"turkey": etfscraper.LocationTurkey,
"belgium": etfscraper.LocationBelgium,
"austria": etfscraper.LocationAustria,
"luxembourg": etfscraper.LocationLuxembourg,
"singapore": etfscraper.LocationSingapore,
"norway": etfscraper.LocationNorway,
"israel": etfscraper.LocationIsrael,
"cash and/or derivatives": etfscraper.LocationCash,
},
MonthTranslations: nil,
DateFormats: []string{"02/Jan/2006"},
DateHeaderPatterns: []string{"Fund Holdings as of"},
ColumnMappings: ColumnMapper{
Name: []string{"Name"},
Ticker: []string{"Ticker"},
ISIN: []string{"ISIN"},
MarketValue: []string{"Market Value"},
Weight: []string{"Weight (%)", "Market Weight"},
Quantity: []string{"Shares", "Quantity"},
Comment thread
yevklym marked this conversation as resolved.
Price: []string{"Price"},
Sector: []string{"Sector"},
AssetClass: []string{"Asset Class"},
Location: []string{"Location"},
Exchange: []string{"Exchange"},
Currency: []string{"Currency"},
MarketCurrency: []string{"Market Currency"},
},
},
"fr": {
DiscoveryURL: "https://www.blackrock.com/fr/particuliers/product-screener/product-screener-v3.1.jsn?dcrPath=/templatedata/config/product-screener-v3/data/fr/France/product-screener-backend-config&siteEntryPassthrough=true",
BaseURL: "https://www.blackrock.com",
Expand Down
54 changes: 54 additions & 0 deletions internal/providers/ishares/holdings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,60 @@ func TestParseHoldings_GermanDottedDate(t *testing.T) {
}
}

func TestParseHoldings_SwissFormat(t *testing.T) {
csvData := "iShares S&P 500 B UCITS ETF (Acc)\n" +
"Fund Holdings as of,\"30/May/2026\"\n" +
"Shares Outstanding,\"100'000'000.00\"\n" +
"\n" +
"Ticker,Name,Asset Class,Market Value,Weight (%),Shares,Price,Location,Exchange,Market Currency\n" +
"\"NOVN\",\"NOVARTIS AG\",\"Equity\",\"9'106'871.33\",\"3.10\",\"910'687.00\",\"100.00\",\"Switzerland\",\"SIX Swiss Exchange\",\"CHF\"\n" +
"\"NESN\",\"NESTLE SA\",\"Equity\",\"8'341'276.35\",\"2.90\",\"834'127.00\",\"10.00\",\"Switzerland\",\"SIX Swiss Exchange\",\"CHF\"\n" +
"\n"

c, err := New("ch")
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}

fund := &etfscraper.Fund{Ticker: "CSPX", Name: "iShares S&P 500 B UCITS ETF (Acc)"}

snapshot, err := c.parseHoldings(context.Background(), strings.NewReader(csvData), fund)
if err != nil {
t.Fatalf("parseHoldings failed: %v", err)
}

expectedDate := time.Date(2026, time.May, 30, 0, 0, 0, 0, time.UTC)
if !snapshot.AsOfDate.Equal(expectedDate) {
t.Errorf("Expected AsOfDate %v, got %v", expectedDate, snapshot.AsOfDate)
}

if snapshot.TotalHoldings != 2 {
t.Errorf("Expected 2 holdings, got %d", snapshot.TotalHoldings)
}

novartis := snapshot.Holdings[0]
if novartis.Ticker != "NOVN" {
t.Errorf("Expected first ticker NOVN, got %s", novartis.Ticker)
}
if novartis.Exchange != etfscraper.ExchangeSIX {
t.Errorf("Expected exchange %s, got %s", etfscraper.ExchangeSIX, novartis.Exchange)
}
if novartis.Currency != etfscraper.CurrencyCHF {
t.Errorf("Expected currency CHF, got %s", novartis.Currency)
}

epsilon := 0.01
if diff := novartis.MarketValue - 9106871.33; diff > epsilon || diff < -epsilon {
t.Errorf("Expected market value 9106871.33, got %f", novartis.MarketValue)
}
if diff := novartis.Weight - 0.031; diff > 0.0001 || diff < -0.0001 {
t.Errorf("Expected weight ~0.031, got %f", novartis.Weight)
}
if diff := novartis.Quantity - 910687.0; diff > epsilon || diff < -epsilon {
t.Errorf("Expected quantity 910687.0, got %f", novartis.Quantity)
}
}

func TestParseHoldings_WhitespaceOnlyRow(t *testing.T) {
// iShares DE CSVs sometimes include a whitespace-only row before the
// disclaimer. This row has a single column containing " " which must
Expand Down
4 changes: 3 additions & 1 deletion internal/providers/ishares/normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func normalizeExchange(value string) etfscraper.Exchange {
normalized := strings.ToUpper(trimmed)

switch normalized {
case "NYSE", "NEW YORK STOCK EXCHANGE":
case "NYSE", "NEW YORK STOCK EXCHANGE", "NEW YORK STOCK EXCHANGE INC.", "NEW YORK STOCK EXCHANGE, INC.":
return etfscraper.ExchangeNYSE
case "NASDAQ":
return etfscraper.ExchangeNASDAQ
Expand All @@ -104,6 +104,8 @@ func normalizeExchange(value string) etfscraper.Exchange {
return etfscraper.ExchangeEuronext
case "XETRA":
return etfscraper.ExchangeXetra
case "SIX", "SIX SWISS EXCHANGE":
return etfscraper.ExchangeSIX
Comment thread
yevklym marked this conversation as resolved.
case "TSE", "TOKYO STOCK EXCHANGE":
return etfscraper.ExchangeTSE
case "HKEX", "HONG KONG EXCHANGE":
Expand Down
2 changes: 2 additions & 0 deletions internal/providers/ishares/normalize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ func TestNormalizeExchange_CommonMappings(t *testing.T) {
{name: "xetra", input: "Xetra", expected: etfscraper.ExchangeXetra},
{name: "euronext", input: "Euronext Paris", expected: etfscraper.ExchangeEuronext},
{name: "nyse", input: "New York Stock Exchange", expected: etfscraper.ExchangeNYSE},
{name: "six code", input: "SIX", expected: etfscraper.ExchangeSIX},
{name: "six full", input: "SIX Swiss Exchange", expected: etfscraper.ExchangeSIX},
}

for _, test := range tests {
Expand Down