Gonçalo Rodrigues c255d7f523 fix(finance): portfolio prices and projections template
Portfolio:
- fetchPrices was passing ISINs directly to Yahoo Finance, which only
  accepts ticker symbols (e.g. VWCE.DE, not IE00B3RBWM25). Added a
  hardcoded isinToTicker map covering common European ETFs (Vanguard,
  iShares, Xtrackers, Amundi). fetchPricesByISIN now resolves each ISIN
  to its ticker before calling Yahoo and keys the result by ISIN so
  computeHoldings can look up prices correctly. Renamed holdingsByISIN
  to uniqueISINs to reflect what it actually returns.

Projections:
- Template had a missing <tr> and a broken pace-bar width expression
  that mixed float64/int64 in the div template function, causing a
  template execution error that rendered the page blank. Moved all
  math server-side: the handler now pre-computes []catProjection with
  MonthlyAvg, AnnualTotal, and PacePct already calculated. Template
  is now simple range + printf. Chart switched to horizontal bar for
  better readability of long category names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 12:47:03 +01:00

269 lines
6.2 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"`
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
"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 / 100)
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 (via isinToTicker),
// fetches the current market price, and returns a map keyed by ISIN.
// ISINs with no known ticker mapping are tried directly as a ticker (fallback).
func fetchPricesByISIN(isins []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, ok := isinToTicker[isin]
if !ok {
ticker = isin // last-resort fallback
}
url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s", ticker)
resp, err := client.Get(url)
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
}