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