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 }