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
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)

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) 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")
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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")
}

View File

@ -4,6 +4,23 @@
{{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="card value-card animate-on-scroll">
<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