From 5d30b5eaeeff9b630815c8ecbb5b4d5b2fd94fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 18:41:13 +0100 Subject: [PATCH 1/7] fix: portfolio current value divided by 100 erroneously currentPrice is already in cents, so currentValue = price * shares. The extra /100 made every holding appear worth 100x less than its cost, producing ~-100% P&L on every position and an empty allocation chart (values too small for Chart.js to render). Co-Authored-By: Claude Sonnet 4.6 --- apps/finance/services/api/main/portfolio.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/finance/services/api/main/portfolio.go b/apps/finance/services/api/main/portfolio.go index e869e18..e23f174 100644 --- a/apps/finance/services/api/main/portfolio.go +++ b/apps/finance/services/api/main/portfolio.go @@ -105,7 +105,7 @@ func computeHoldings(trades []Trade, prices map[string]int64) []Holding { currentPrice := prices[isin] - currentValue := int64(float64(currentPrice) * a.shares / 100) + currentValue := int64(float64(currentPrice) * a.shares) unrealizedPCL := currentValue - a.cost pct := 0.0 if a.cost > 0 { From d77020dcc5e4ff967dd4dc4315246bcec2a13bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 18:49:49 +0100 Subject: [PATCH 2/7] fix: add User-Agent header to Yahoo Finance price requests Without it the API returns "Too Many Requests" (not JSON), prices map stays empty, currentValue = 0, and every holding shows -100% P&L with an empty allocation chart. Co-Authored-By: Claude Sonnet 4.6 --- apps/finance/services/api/main/portfolio.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/finance/services/api/main/portfolio.go b/apps/finance/services/api/main/portfolio.go index e23f174..52e1532 100644 --- a/apps/finance/services/api/main/portfolio.go +++ b/apps/finance/services/api/main/portfolio.go @@ -167,7 +167,12 @@ func fetchPricesByISIN(isins []string) (map[string]int64, error) { } url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s", ticker) - resp, err := client.Get(url) + 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 From a2d3b605007eca2ff876d94759fba8bf4871925e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 18:55:38 +0100 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20allocation=20chart=20=E2=80=94=20ill?= =?UTF-8?q?egal=20top-level=20return=20in=20ES=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `return` at the top level of a {{else}} From 5dc920cb1a09e6ef0b9a0b9f83e1c6548683e8ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 19:00:56 +0100 Subject: [PATCH 4/7] fix: add QDVE.DE ticker mapping for iShares S&P 500 IT Sector ETF (IE00B3WJKG14) Co-Authored-By: Claude Sonnet 4.6 --- apps/finance/services/api/main/portfolio.go | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/finance/services/api/main/portfolio.go b/apps/finance/services/api/main/portfolio.go index 52e1532..a69c0f7 100644 --- a/apps/finance/services/api/main/portfolio.go +++ b/apps/finance/services/api/main/portfolio.go @@ -24,6 +24,7 @@ var isinToTicker = map[string]string{ "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 From 6712c3608116cc218c05a7f54ef8ebf7aa6e1f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 19:05:36 +0100 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20user-editable=20ISIN=E2=86=92ticker?= =?UTF-8?q?=20mappings=20for=20unrecognised=20holdings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/finance/services/api/main/handler.go | 42 ++++++++++++++++-- .../finance/services/api/main/handler_test.go | 5 +++ apps/finance/services/api/main/models.go | 2 + apps/finance/services/api/main/portfolio.go | 16 ++++--- apps/finance/services/api/main/store.go | 30 +++++++++++++ .../api/main/templates/portfolio.html | 17 +++++++ .../services/api/main/testdata/.DS_Store | Bin 0 -> 6148 bytes .../testdata/traderepublic_securities.csv | 6 --- 8 files changed, 103 insertions(+), 15 deletions(-) create mode 100644 apps/finance/services/api/main/testdata/.DS_Store delete mode 100644 apps/finance/services/api/main/testdata/traderepublic_securities.csv diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index 4be62c7..a8c4fb1 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -186,6 +186,8 @@ type storeIface interface { updateGoal(ctx context.Context, id, userID string, update bson.M) error deleteGoal(ctx context.Context, id, userID string) error seedCategories(ctx context.Context, userID string) error + getTickerMappings(ctx context.Context, userID string) ([]TickerMapping, error) + saveTickerMapping(ctx context.Context, userID, isin, ticker string) error getHousehold(ctx context.Context, userID string) (*Household, error) createHousehold(ctx context.Context, h *Household) error deleteHousehold(ctx context.Context, userID string) error @@ -460,7 +462,7 @@ func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { var portfolioHoldings []Holding var portfolioPricesAvailable bool if trades, err := h.store.getTrades(ctx, a.UserID); err == nil && len(trades) > 0 { - prices, _ := fetchPricesByISIN(uniqueISINs(trades)) + prices, _ := fetchPricesByISIN(uniqueISINs(trades), nil) holdings := computeHoldings(trades, prices) pr := aggregatePortfolio(holdings) portfolioHoldings = pr.Holdings @@ -1330,11 +1332,26 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) { return } - prices, err := fetchPricesByISIN(isins) + // load user-saved ISIN→ticker overrides + customMappings, _ := h.store.getTickerMappings(ctx, a.UserID) + custom := make(map[string]string, len(customMappings)) + for _, m := range customMappings { + custom[m.ISIN] = m.Ticker + } + + prices, err := fetchPricesByISIN(isins, custom) if err != nil { slog.Error("fetch prices", "err", err) } + // collect ISINs for which we got no price + var missingPrices []string + for _, isin := range isins { + if prices[isin] == 0 { + missingPrices = append(missingPrices, isin) + } + } + holdings := computeHoldings(trades, prices) pr := aggregatePortfolio(holdings) @@ -1348,9 +1365,27 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) { TotalCostCents: pr.TotalCost, TotalPCLCents: pr.TotalPCL, TotalPCLPct: pr.PCLPct, + MissingPrices: missingPrices, }) } +func (h *Handler) SaveTickerMapping(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + a := getAuth(r) + isin := strings.TrimSpace(r.FormValue("isin")) + ticker := strings.TrimSpace(r.FormValue("ticker")) + if isin == "" || ticker == "" { + http.Error(w, "isin and ticker required", http.StatusBadRequest) + return + } + if err := h.store.saveTickerMapping(ctx, a.UserID, isin, ticker); err != nil { + slog.Error("save ticker mapping", "err", err) + http.Error(w, "save error", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/portfolio", http.StatusSeeOther) +} + func (h *Handler) ImportSecurities(w http.ResponseWriter, r *http.Request) { ctx := r.Context() a := getAuth(r) @@ -1884,7 +1919,7 @@ func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) { var portfolioCents int64 var pricesAvailable bool if trades, err2 := h.store.getTrades(ctx, a.UserID); err2 == nil && len(trades) > 0 { - prices, _ := fetchPricesByISIN(uniqueISINs(trades)) + prices, _ := fetchPricesByISIN(uniqueISINs(trades), nil) holdings := computeHoldings(trades, prices) pr := aggregatePortfolio(holdings) for _, p := range prices { @@ -2414,6 +2449,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /import/preview", h.ImportPreview) mux.HandleFunc("POST /import/confirm", h.ImportConfirm) mux.HandleFunc("POST /import/securities", h.ImportSecurities) + mux.HandleFunc("POST /portfolio/ticker", h.SaveTickerMapping) mux.HandleFunc("POST /accounts", h.Accounts) mux.HandleFunc("DELETE /accounts/{id}", h.Accounts) mux.HandleFunc("POST /categories", h.Categories) diff --git a/apps/finance/services/api/main/handler_test.go b/apps/finance/services/api/main/handler_test.go index b93ea51..608a0cd 100644 --- a/apps/finance/services/api/main/handler_test.go +++ b/apps/finance/services/api/main/handler_test.go @@ -154,6 +154,11 @@ func (m *mockStore) deleteGoal(_ context.Context, id, _ string) error { } func (m *mockStore) seedCategories(_ context.Context, _ string) error { return nil } +func (m *mockStore) getTickerMappings(_ context.Context, _ string) ([]TickerMapping, error) { + return nil, nil +} +func (m *mockStore) saveTickerMapping(_ context.Context, _, _, _ string) error { return nil } + func (m *mockStore) getHousehold(_ context.Context, _ string) (*Household, error) { return nil, fmt.Errorf("not found") } diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index fa15ee0..d00e6e5 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -207,6 +207,8 @@ type PortfolioData struct { TotalPCLCents int64 TotalPCLPct float64 RealizedPCLCents int64 + // ISINs for which no price could be fetched (so user can supply a ticker) + MissingPrices []string } type SharingData struct { diff --git a/apps/finance/services/api/main/portfolio.go b/apps/finance/services/api/main/portfolio.go index a69c0f7..dc114b3 100644 --- a/apps/finance/services/api/main/portfolio.go +++ b/apps/finance/services/api/main/portfolio.go @@ -13,6 +13,7 @@ import ( type TickerMapping struct { ISIN string `bson:"_id" json:"isin"` + UserID string `bson:"user_id" json:"user_id"` Ticker string `bson:"ticker" json:"ticker"` } @@ -150,10 +151,10 @@ type TickerStore interface { 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) { +// 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 } @@ -162,8 +163,11 @@ func fetchPricesByISIN(isins []string) (map[string]int64, error) { client := &http.Client{Timeout: 10 * time.Second} for _, isin := range isins { - ticker, ok := isinToTicker[isin] - if !ok { + ticker := custom[isin] + if ticker == "" { + ticker = isinToTicker[isin] + } + if ticker == "" { ticker = isin // last-resort fallback } diff --git a/apps/finance/services/api/main/store.go b/apps/finance/services/api/main/store.go index da99640..df5fa33 100644 --- a/apps/finance/services/api/main/store.go +++ b/apps/finance/services/api/main/store.go @@ -328,6 +328,36 @@ var defaultCategories = []struct { {"Other", "#9E9E9E"}, } +func (s *Store) tickerMappings() *mgmongo.Collection { + return s.db.Collection("finance_ticker_mappings") +} + +func (s *Store) getTickerMappings(ctx context.Context, userID string) ([]TickerMapping, error) { + ctx, span := mongo.StartSpan(ctx, "Store.getTickerMappings") + defer span.End() + cur, err := s.tickerMappings().Find(ctx, bson.M{"user_id": userID}) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + var items []TickerMapping + if err := cur.All(ctx, &items); err != nil { + return nil, err + } + return items, nil +} + +func (s *Store) saveTickerMapping(ctx context.Context, userID, isin, ticker string) error { + ctx, span := mongo.StartSpan(ctx, "Store.saveTickerMapping") + defer span.End() + _, err := s.tickerMappings().UpdateOne(ctx, + bson.M{"_id": isin, "user_id": userID}, + bson.M{"$set": bson.M{"ticker": ticker, "user_id": userID}}, + options.UpdateOne().SetUpsert(true), + ) + return err +} + func (s *Store) households() *mgmongo.Collection { return s.db.Collection("finance_households") } diff --git a/apps/finance/services/api/main/templates/portfolio.html b/apps/finance/services/api/main/templates/portfolio.html index 1e36af9..45f33e8 100644 --- a/apps/finance/services/api/main/templates/portfolio.html +++ b/apps/finance/services/api/main/templates/portfolio.html @@ -4,6 +4,23 @@ {{if $d.Holdings}} +{{if $d.MissingPrices}} +
+
⚠ Live price unavailable for {{len $d.MissingPrices}} holding{{if gt (len $d.MissingPrices) 1}}s{{end}} — add a Yahoo Finance ticker to fix this
+ {{range $d.MissingPrices}} +
+ + {{.}} + + + Look up ↗ +
+ {{end}} +
+{{end}} +

Total Value

diff --git a/apps/finance/services/api/main/testdata/.DS_Store b/apps/finance/services/api/main/testdata/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 Date: Sat, 13 Jun 2026 19:09:02 +0100 Subject: [PATCH 6/7] style: blue-green palette for portfolio allocation chart Co-Authored-By: Claude Sonnet 4.6 --- apps/finance/services/api/main/templates/portfolio.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/finance/services/api/main/templates/portfolio.html b/apps/finance/services/api/main/templates/portfolio.html index 45f33e8..6d8354b 100644 --- a/apps/finance/services/api/main/templates/portfolio.html +++ b/apps/finance/services/api/main/templates/portfolio.html @@ -110,8 +110,8 @@ import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; const palette = [ - '#6979f8','#f87171','#fbbf24','#34d399','#a78bfa', - '#f472b6','#38bdf8','#fb923c','#4ade80','#e879f9', + '#00d4ff','#0099cc','#00b894','#007a63','#005f8a', + '#00e5cc','#0077b6','#48cae4','#023e8a','#00b4d8', ]; const holdings = [ From 09984272cd561cc2959502f256e5bd01e545b2d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gon=C3=A7alo=20Rodrigues?= Date: Sat, 13 Jun 2026 19:12:58 +0100 Subject: [PATCH 7/7] style: dark teal/cyan theme across the whole app Replace indigo accent palette with deep black backgrounds and cyan-teal accents (#00c9b8 dark, #00897b light). Borders, glows, text muted tones and background gradients all updated to match. Co-Authored-By: Claude Sonnet 4.6 --- .../services/api/main/templates/base.html | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/apps/finance/services/api/main/templates/base.html b/apps/finance/services/api/main/templates/base.html index 3f5d3fa..5b124cf 100644 --- a/apps/finance/services/api/main/templates/base.html +++ b/apps/finance/services/api/main/templates/base.html @@ -8,51 +8,51 @@