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:
parent
5dc920cb1a
commit
6712c36081
@ -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)
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
BIN
apps/finance/services/api/main/testdata/.DS_Store
vendored
Normal file
BIN
apps/finance/services/api/main/testdata/.DS_Store
vendored
Normal file
Binary file not shown.
@ -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","","","",""
|
||||
|
Loading…
x
Reference in New Issue
Block a user