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>
This commit is contained in:
Gonçalo Rodrigues 2026-06-13 19:05:36 +01:00
parent 5dc920cb1a
commit 6712c36081
8 changed files with 103 additions and 15 deletions

View File

@ -186,6 +186,8 @@ type storeIface interface {
updateGoal(ctx context.Context, id, userID string, update bson.M) error updateGoal(ctx context.Context, id, userID string, update bson.M) error
deleteGoal(ctx context.Context, id, userID string) error deleteGoal(ctx context.Context, id, userID string) error
seedCategories(ctx context.Context, 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) getHousehold(ctx context.Context, userID string) (*Household, error)
createHousehold(ctx context.Context, h *Household) error createHousehold(ctx context.Context, h *Household) error
deleteHousehold(ctx context.Context, userID string) 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 portfolioHoldings []Holding
var portfolioPricesAvailable bool var portfolioPricesAvailable bool
if trades, err := h.store.getTrades(ctx, a.UserID); err == nil && len(trades) > 0 { 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) holdings := computeHoldings(trades, prices)
pr := aggregatePortfolio(holdings) pr := aggregatePortfolio(holdings)
portfolioHoldings = pr.Holdings portfolioHoldings = pr.Holdings
@ -1330,11 +1332,26 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
return 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 { if err != nil {
slog.Error("fetch prices", "err", err) 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) holdings := computeHoldings(trades, prices)
pr := aggregatePortfolio(holdings) pr := aggregatePortfolio(holdings)
@ -1348,9 +1365,27 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
TotalCostCents: pr.TotalCost, TotalCostCents: pr.TotalCost,
TotalPCLCents: pr.TotalPCL, TotalPCLCents: pr.TotalPCL,
TotalPCLPct: pr.PCLPct, 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) { func (h *Handler) ImportSecurities(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
a := getAuth(r) a := getAuth(r)
@ -1884,7 +1919,7 @@ func (h *Handler) NetWorth(w http.ResponseWriter, r *http.Request) {
var portfolioCents int64 var portfolioCents int64
var pricesAvailable bool var pricesAvailable bool
if trades, err2 := h.store.getTrades(ctx, a.UserID); err2 == nil && len(trades) > 0 { 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) holdings := computeHoldings(trades, prices)
pr := aggregatePortfolio(holdings) pr := aggregatePortfolio(holdings)
for _, p := range prices { 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/preview", h.ImportPreview)
mux.HandleFunc("POST /import/confirm", h.ImportConfirm) mux.HandleFunc("POST /import/confirm", h.ImportConfirm)
mux.HandleFunc("POST /import/securities", h.ImportSecurities) mux.HandleFunc("POST /import/securities", h.ImportSecurities)
mux.HandleFunc("POST /portfolio/ticker", h.SaveTickerMapping)
mux.HandleFunc("POST /accounts", h.Accounts) mux.HandleFunc("POST /accounts", h.Accounts)
mux.HandleFunc("DELETE /accounts/{id}", h.Accounts) mux.HandleFunc("DELETE /accounts/{id}", h.Accounts)
mux.HandleFunc("POST /categories", h.Categories) mux.HandleFunc("POST /categories", h.Categories)

View File

@ -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) 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) { func (m *mockStore) getHousehold(_ context.Context, _ string) (*Household, error) {
return nil, fmt.Errorf("not found") return nil, fmt.Errorf("not found")
} }

View File

@ -207,6 +207,8 @@ type PortfolioData struct {
TotalPCLCents int64 TotalPCLCents int64
TotalPCLPct float64 TotalPCLPct float64
RealizedPCLCents int64 RealizedPCLCents int64
// ISINs for which no price could be fetched (so user can supply a ticker)
MissingPrices []string
} }
type SharingData struct { type SharingData struct {

View File

@ -13,6 +13,7 @@ import (
type TickerMapping struct { type TickerMapping struct {
ISIN string `bson:"_id" json:"isin"` ISIN string `bson:"_id" json:"isin"`
UserID string `bson:"user_id" json:"user_id"`
Ticker string `bson:"ticker" json:"ticker"` Ticker string `bson:"ticker" json:"ticker"`
} }
@ -150,10 +151,10 @@ type TickerStore interface {
Load() error Load() error
} }
// fetchPricesByISIN resolves each ISIN to a Yahoo Finance ticker (via isinToTicker), // fetchPricesByISIN resolves each ISIN to a Yahoo Finance ticker, checking
// fetches the current market price, and returns a map keyed by ISIN. // custom (user-saved) mappings first, then the hardcoded isinToTicker map,
// ISINs with no known ticker mapping are tried directly as a ticker (fallback). // then falling back to the raw ISIN as a last resort.
func fetchPricesByISIN(isins []string) (map[string]int64, error) { func fetchPricesByISIN(isins []string, custom map[string]string) (map[string]int64, error) {
if len(isins) == 0 { if len(isins) == 0 {
return map[string]int64{}, nil return map[string]int64{}, nil
} }
@ -162,8 +163,11 @@ func fetchPricesByISIN(isins []string) (map[string]int64, error) {
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
for _, isin := range isins { for _, isin := range isins {
ticker, ok := isinToTicker[isin] ticker := custom[isin]
if !ok { if ticker == "" {
ticker = isinToTicker[isin]
}
if ticker == "" {
ticker = isin // last-resort fallback ticker = isin // last-resort fallback
} }

View File

@ -328,6 +328,36 @@ var defaultCategories = []struct {
{"Other", "#9E9E9E"}, {"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 { func (s *Store) households() *mgmongo.Collection {
return s.db.Collection("finance_households") return s.db.Collection("finance_households")
} }

View File

@ -4,6 +4,23 @@
{{if $d.Holdings}} {{if $d.Holdings}}
{{if $d.MissingPrices}}
<div style="background:rgba(245,158,11,0.08); border:1px solid rgba(245,158,11,0.35); border-radius:12px; padding:16px 20px; margin-bottom:20px;">
<div style="font-weight:600; font-size:0.9rem; margin-bottom:12px;">⚠ Live price unavailable for {{len $d.MissingPrices}} holding{{if gt (len $d.MissingPrices) 1}}s{{end}} — add a Yahoo Finance ticker to fix this</div>
{{range $d.MissingPrices}}
<form method="post" action="/portfolio/ticker" style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">
<input type="hidden" name="isin" value="{{.}}">
<code style="font-size:0.8rem; color:var(--muted); min-width:140px;">{{.}}</code>
<input type="text" name="ticker" placeholder="e.g. QDVE.DE" required
style="padding:6px 10px; border:1px solid var(--border); border-radius:8px; background:var(--bg); color:var(--text); font-size:0.85rem; width:130px;">
<button type="submit" style="padding:6px 14px; background:var(--accent); color:#fff; border:none; border-radius:8px; font-size:0.85rem; font-weight:600; cursor:pointer;">Save</button>
<a href="https://finance.yahoo.com/lookup/" target="_blank" rel="noopener"
style="font-size:0.78rem; color:var(--accent);">Look up ↗</a>
</form>
{{end}}
</div>
{{end}}
<div class="grid"> <div class="grid">
<div class="card value-card animate-on-scroll"> <div class="card value-card animate-on-scroll">
<h2>Total Value</h2> <h2>Total Value</h2>

Binary file not shown.

View File

@ -1,6 +0,0 @@
"datetime","date","account_type","category","type","asset_class","name","symbol","shares","price","amount","fee","tax","currency","original_amount","original_currency","fx_rate","description","transaction_id","counterparty_name","counterparty_iban","payment_reference","mcc_code"
"2025-12-11T08:08:30.375Z","2025-12-11","DEFAULT","TRADING","BUY","FUND","S&P 500 Information Tech USD (Acc)","IE00B3WJKG14","0.8289580000","36.1900000000","-30.00","-1.00","","EUR","","","","Buy trade IE00B3WJKG14 iShares S&P 500 Info Tech UCITS ETF USD (Acc), quantity: 0.828958","905f97ce-8c20-4891-8713-cb084b0cecb1","","","",""
"2025-12-11T08:09:16.732Z","2025-12-11","DEFAULT","TRADING","BUY","FUND","Core S&P 500 USD (Acc)","IE00B5BMR087","0.0287900000","625.1800000000","-18.00","-1.00","","EUR","","","","Buy trade IE00B5BMR087 iShares Core S&P 500 UCITS ETF USD (Acc), quantity: 0.02879","8bf8206a-a90e-4df4-98d5-3473dcf1c7d6","","","",""
"2026-02-26T07:53:08.309Z","2026-02-26","DEFAULT","TRADING","BUY","FUND","Core S&P 500 USD (Acc)","IE00B5BMR087","0.1586540000","630.3000000000","-100.00","-1.00","","EUR","","","","Buy trade IE00B5BMR087 iShares Core S&P 500 UCITS ETF USD (Acc), quantity: 0.158654","c154e2fd-7e13-43dc-b78f-48a0bf4b9fc6","","","",""
"2026-02-26T07:54:29.927Z","2026-02-26","DEFAULT","TRADING","BUY","FUND","Core MSCI World USD (Acc)","IE00B4L5Y983","0.8578050000","114.2500000000","-98.00","-1.00","","EUR","","","","Buy trade IE00B4L5Y983 iShares Core MSCI World UCITS ETF USD (Acc), quantity: 0.857805","7c79b9aa-82ce-48e7-bf93-20786b7977b5","","","",""
"2026-04-15T07:28:37.604Z","2026-04-15","DEFAULT","TRADING","BUY","FUND","Core S&P 500 USD (Acc)","IE00B5BMR087","0.1575100000","634.8800000000","-100.00","-1.00","","EUR","","","","Buy trade IE00B5BMR087 iShares Core S&P 500 UCITS ETF USD (Acc), quantity: 0.15751","4062d871-f968-483a-8f1f-16447c877201","","","",""
1 datetime date account_type category type asset_class name symbol shares price amount fee tax currency original_amount original_currency fx_rate description transaction_id counterparty_name counterparty_iban payment_reference mcc_code
2 2025-12-11T08:08:30.375Z 2025-12-11 DEFAULT TRADING BUY FUND S&P 500 Information Tech USD (Acc) IE00B3WJKG14 0.8289580000 36.1900000000 -30.00 -1.00 EUR Buy trade IE00B3WJKG14 iShares S&P 500 Info Tech UCITS ETF USD (Acc), quantity: 0.828958 905f97ce-8c20-4891-8713-cb084b0cecb1
3 2025-12-11T08:09:16.732Z 2025-12-11 DEFAULT TRADING BUY FUND Core S&P 500 USD (Acc) IE00B5BMR087 0.0287900000 625.1800000000 -18.00 -1.00 EUR Buy trade IE00B5BMR087 iShares Core S&P 500 UCITS ETF USD (Acc), quantity: 0.02879 8bf8206a-a90e-4df4-98d5-3473dcf1c7d6
4 2026-02-26T07:53:08.309Z 2026-02-26 DEFAULT TRADING BUY FUND Core S&P 500 USD (Acc) IE00B5BMR087 0.1586540000 630.3000000000 -100.00 -1.00 EUR Buy trade IE00B5BMR087 iShares Core S&P 500 UCITS ETF USD (Acc), quantity: 0.158654 c154e2fd-7e13-43dc-b78f-48a0bf4b9fc6
5 2026-02-26T07:54:29.927Z 2026-02-26 DEFAULT TRADING BUY FUND Core MSCI World USD (Acc) IE00B4L5Y983 0.8578050000 114.2500000000 -98.00 -1.00 EUR Buy trade IE00B4L5Y983 iShares Core MSCI World UCITS ETF USD (Acc), quantity: 0.857805 7c79b9aa-82ce-48e7-bf93-20786b7977b5
6 2026-04-15T07:28:37.604Z 2026-04-15 DEFAULT TRADING BUY FUND Core S&P 500 USD (Acc) IE00B5BMR087 0.1575100000 634.8800000000 -100.00 -1.00 EUR Buy trade IE00B5BMR087 iShares Core S&P 500 UCITS ETF USD (Acc), quantity: 0.15751 4062d871-f968-483a-8f1f-16447c877201