Gonçalo Rodrigues 6712c36081 feat: user-editable ISIN→ticker mappings for unrecognised holdings
When a holding has no price (ISIN not in the built-in map and Yahoo
rejects the raw ISIN), the portfolio page now shows an amber banner
listing each missing ISIN with an inline text input and a "Look up"
link to Yahoo Finance symbol search.

Submitting the form POSTs to /portfolio/ticker which upserts the mapping
into a finance_ticker_mappings collection keyed by (user_id, ISIN).
On the next page load custom mappings are resolved first, before the
hardcoded isinToTicker table, so user overrides always win.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 19:05:36 +01:00

279 lines
6.5 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"math"
"net/http"
"sort"
"time"
)
type TickerMapping struct {
ISIN string `bson:"_id" json:"isin"`
UserID string `bson:"user_id" json:"user_id"`
Ticker string `bson:"ticker" json:"ticker"`
}
// isinToTicker maps well-known ISIN codes to their Yahoo Finance ticker symbols.
// Add entries here for any ETF/stock you trade that isn't covered yet.
var isinToTicker = map[string]string{
// Vanguard
"IE00B3RBWM25": "VWCE.DE", // VWCE — All-World
"IE00B3XXRP09": "VWRL.AS", // VWRL — All-World
"IE00BK5BQT80": "VWRA.L", // VWRA — All-World (acc, LSE)
// iShares
"IE00B3WJKG14": "QDVE.DE", // QDVE — S&P 500 Information Technology
"IE00B4L5Y983": "EUNL.DE", // EUNL — MSCI World
"IE00B5BMR087": "SXR8.DE", // SXR8 — S&P 500
"IE00B4K48X80": "SXRV.DE", // SXRV — S&P 500 EUR hedged
"IE00B52MJY50": "IUSA.AS", // IUSA — S&P 500
"IE00B0M63177": "IWRD.AS", // IWRD — MSCI World
"IE00B4ND3602": "EMIM.AS", // EMIM — Emerging Markets
"IE00BKM4GZ66": "IS3N.DE", // IS3N — Core MSCI EM
"IE00B4L5YC18": "CSSPX.MI", // CSSPX — Core S&P 500
// Xtrackers
"LU0274208692": "DBXW.DE", // DBXW — MSCI World
"LU0490618542": "XMWO.DE", // XMWO — MSCI World Swap
// Amundi
"LU1681043599": "CW8.PA", // CW8 — MSCI World
"FR0010315770": "CU2.PA", // CU2 — S&P 500
}
type yahooChartResponse struct {
Chart struct {
Result []struct {
Meta struct {
RegularMarketPrice float64 `json:"regularMarketPrice"`
} `json:"meta"`
} `json:"result"`
} `json:"chart"`
}
func computeHoldings(trades []Trade, prices map[string]int64) []Holding {
type acc struct {
shares float64
cost int64
realizedPCL int64
}
byISIN := make(map[string]*acc)
var isinOrder []string
for _, t := range trades {
a, ok := byISIN[t.ISIN]
if !ok {
a = &acc{}
byISIN[t.ISIN] = a
isinOrder = append(isinOrder, t.ISIN)
}
if t.Type == "buy" || t.Type == "Buy" {
a.shares += t.Quantity
a.cost += t.TotalCents
} else {
if a.shares > 0 {
avgCost := int64(float64(a.cost) / a.shares * t.Quantity)
a.realizedPCL += t.TotalCents - avgCost
}
a.shares -= t.Quantity
if a.shares < 0 {
a.shares = 0
}
}
}
var holdings []Holding
for _, isin := range isinOrder {
a := byISIN[isin]
if a.shares < 0.0001 {
continue
}
var name string
for _, t := range trades {
if t.ISIN == isin {
name = t.Name
break
}
}
avgEntry := int64(0)
if a.shares > 0 {
avgEntry = int64(float64(a.cost) / a.shares)
}
currentPrice := prices[isin]
currentValue := int64(float64(currentPrice) * a.shares)
unrealizedPCL := currentValue - a.cost
pct := 0.0
if a.cost > 0 {
pct = (float64(unrealizedPCL) / float64(a.cost)) * 100
}
holdings = append(holdings, Holding{
ISIN: isin,
Name: name,
SharesOwned: math.Round(a.shares*10000) / 10000,
AvgEntryCents: avgEntry,
TotalCostCents: a.cost,
CurrentPriceCents: currentPrice,
CurrentValueCents: currentValue,
UnrealizedPCLCents: unrealizedPCL,
UnrealizedPCLPct: math.Round(pct*100) / 100,
})
}
return holdings
}
type tickerStore struct {
mappings []TickerMapping
}
func (s *tickerStore) resolve(isin string) string {
for _, m := range s.mappings {
if m.ISIN == isin {
return m.Ticker
}
}
return ""
}
var defaultTickerMappings = []TickerMapping{}
type TickerStore interface {
Resolve(isin string) string
Save(isin, ticker string) error
Load() error
}
// fetchPricesByISIN resolves each ISIN to a Yahoo Finance ticker, checking
// custom (user-saved) mappings first, then the hardcoded isinToTicker map,
// then falling back to the raw ISIN as a last resort.
func fetchPricesByISIN(isins []string, custom map[string]string) (map[string]int64, error) {
if len(isins) == 0 {
return map[string]int64{}, nil
}
result := make(map[string]int64)
client := &http.Client{Timeout: 10 * time.Second}
for _, isin := range isins {
ticker := custom[isin]
if ticker == "" {
ticker = isinToTicker[isin]
}
if ticker == "" {
ticker = isin // last-resort fallback
}
url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s", ticker)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
continue
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; homelab-finance/1.0)")
resp, err := client.Do(req)
if err != nil {
slog.Warn("price fetch failed", "isin", isin, "ticker", ticker, "err", err)
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
continue
}
var chart yahooChartResponse
if err := json.Unmarshal(body, &chart); err != nil {
continue
}
if len(chart.Chart.Result) > 0 {
price := chart.Chart.Result[0].Meta.RegularMarketPrice
if price > 0 {
result[isin] = int64(price * 100)
slog.Info("price fetched", "isin", isin, "ticker", ticker, "price_cents", result[isin])
}
}
}
return result, nil
}
func uniqueISINs(trades []Trade) []string {
seen := make(map[string]bool)
var result []string
for _, t := range trades {
if !seen[t.ISIN] {
seen[t.ISIN] = true
result = append(result, t.ISIN)
}
}
return result
}
type PortfolioResult struct {
Holdings []Holding
TotalCost int64
TotalVal int64
TotalPCL int64
PCLPct float64
}
func aggregatePortfolio(holdings []Holding) PortfolioResult {
var pr PortfolioResult
for _, h := range holdings {
pr.Holdings = append(pr.Holdings, h)
pr.TotalCost += h.TotalCostCents
pr.TotalVal += h.CurrentValueCents
}
pr.TotalPCL = pr.TotalVal - pr.TotalCost
if pr.TotalCost > 0 {
pr.PCLPct = math.Round(float64(pr.TotalPCL)/float64(pr.TotalCost)*10000) / 100
}
sort.Slice(pr.Holdings, func(i, j int) bool {
return pr.Holdings[i].CurrentValueCents > pr.Holdings[j].CurrentValueCents
})
return pr
}
type CategorySummary struct {
Category string
Cents int64
Count int
}
func topMerchants(transactions []Transaction, limit int) []CategorySummary {
type acc struct {
cents int64
count int
}
byDesc := make(map[string]*acc)
for _, t := range transactions {
a, ok := byDesc[t.Description]
if !ok {
a = &acc{}
byDesc[t.Description] = a
}
a.cents += t.AmountCents
a.count++
}
var result []CategorySummary
for desc, a := range byDesc {
result = append(result, CategorySummary{Category: desc, Cents: a.cents, Count: a.count})
}
sort.Slice(result, func(i, j int) bool {
return result[i].Cents < result[j].Cents
})
if len(result) > limit {
result = result[:limit]
}
return result
}