diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index ffbdae0..01e1704 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -933,6 +933,41 @@ func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) { } annualTotal := int64(math.Round(float64(-totalSpend) / float64(monthCount) * 12)) + monthlyTotal := float64(annualTotal) / 12 + + // pre-compute pace percentage per category for the template (avoids float/int type issues) + type catProjection struct { + Name string + MonthlyAvg float64 + AnnualTotal float64 + PacePct int + } + cats2, _ := h.store.getCategories(ctx, a.UserID) + catColors2 := make(map[string]string) + for _, c := range cats2 { + catColors2[c.Name] = c.Color + } + + var projections []catProjection + for cat, avg := range monthlyAvg { + pct := 0 + if monthlyTotal > 0 { + pct = int(math.Round(avg / monthlyTotal * 100)) + if pct > 100 { + pct = 100 + } + } + projections = append(projections, catProjection{ + Name: cat, + MonthlyAvg: avg, + AnnualTotal: avg * 12, + PacePct: pct, + }) + } + // sort by monthly avg descending + sort.Slice(projections, func(i, j int) bool { + return projections[i].MonthlyAvg > projections[j].MonthlyAvg + }) render(w, projectionsTmpl, map[string]interface{}{ "UserID": a.UserID, @@ -940,9 +975,9 @@ func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) { "Title": "Projections", "Route": "projections", "IsOwner": true, - "MonthlyAvg": monthlyAvg, + "Projections": projections, "AnnualTotal": annualTotal, - "CategoryNames": catNames, + "CategoryColors": catColors2, }) } @@ -957,8 +992,8 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) { return } - tickers := holdingsByISIN(trades) - if len(tickers) == 0 { + isins := uniqueISINs(trades) + if len(isins) == 0 { render(w, portfolioTmpl, &PortfolioData{ UserID: a.UserID, Email: a.Email, @@ -968,7 +1003,7 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) { return } - prices, err := fetchPrices(tickers) + prices, err := fetchPricesByISIN(isins) if err != nil { slog.Error("fetch prices", "err", err) } diff --git a/apps/finance/services/api/main/portfolio.go b/apps/finance/services/api/main/portfolio.go index 99f6f6c..e869e18 100644 --- a/apps/finance/services/api/main/portfolio.go +++ b/apps/finance/services/api/main/portfolio.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "math" "net/http" "sort" @@ -15,6 +16,30 @@ type TickerMapping struct { 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 { @@ -124,21 +149,27 @@ type TickerStore interface { Load() error } -func fetchPrices(tickers []string) (map[string]int64, error) { - if len(tickers) == 0 { +// 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 _, ticker := range tickers { - if ticker == "" { - continue + 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) @@ -154,14 +185,17 @@ func fetchPrices(tickers []string) (map[string]int64, error) { if len(chart.Chart.Result) > 0 { price := chart.Chart.Result[0].Meta.RegularMarketPrice - result[ticker] = int64(price * 100) + if price > 0 { + result[isin] = int64(price * 100) + slog.Info("price fetched", "isin", isin, "ticker", ticker, "price_cents", result[isin]) + } } } return result, nil } -func holdingsByISIN(trades []Trade) []string { +func uniqueISINs(trades []Trade) []string { seen := make(map[string]bool) var result []string for _, t := range trades { diff --git a/apps/finance/services/api/main/portfolio_test.go b/apps/finance/services/api/main/portfolio_test.go index 473a312..15eb733 100644 --- a/apps/finance/services/api/main/portfolio_test.go +++ b/apps/finance/services/api/main/portfolio_test.go @@ -133,7 +133,7 @@ func TestHoldingsByISIN(t *testing.T) { {ISIN: "US0378331005"}, {ISIN: "IE00B0M62X35"}, } - got := holdingsByISIN(trades) + got := uniqueISINs(trades) want := []string{"IE00B0M62X35", "US0378331005"} if len(got) != len(want) { t.Fatalf("got %d isins, want %d: %v", len(got), len(want), got) diff --git a/apps/finance/services/api/main/templates/projections.html b/apps/finance/services/api/main/templates/projections.html index b48983a..e3eacfa 100644 --- a/apps/finance/services/api/main/templates/projections.html +++ b/apps/finance/services/api/main/templates/projections.html @@ -2,24 +2,26 @@ {{$d := .}}
Based on 6-month average
+Based on 6-month average
Average across categories
+Average across all categories
Import at least a month of transactions to see projections.
+ Import Transactions +