Merge pull request #18 from GoncaloRodri/feature/nav-cleanup

Feature/nav cleanup
This commit is contained in:
Gonçalo Rodrigues 2026-06-13 19:31:12 +01:00 committed by GitHub
commit b1c9609f42
9 changed files with 157 additions and 60 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"`
}
@ -24,6 +25,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
@ -105,7 +107,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 {
@ -149,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
}
@ -161,13 +163,21 @@ 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
}
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

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

@ -8,51 +8,51 @@
<style>
/* ── Tokens ─────────────────────────────────────────────────────── */
:root {
--bg: #0f1117;
--bg2: #161b27;
--bg3: #1e2535;
--surface: rgba(30, 37, 53, 0.85);
--surface2: rgba(40, 50, 72, 0.7);
--border: rgba(255,255,255,0.07);
--border2: rgba(255,255,255,0.12);
--text: #e8eaf6;
--text2: #9fa8c7;
--text3: #5c6585;
--accent: #6979f8;
--accent2: #8b9ffc;
--accent-glow: rgba(105,121,248,0.25);
--green: #4ade80;
--bg: #080c10;
--bg2: #0d1318;
--bg3: #131c24;
--surface: rgba(13, 22, 32, 0.88);
--surface2: rgba(20, 32, 44, 0.75);
--border: rgba(0,210,200,0.08);
--border2: rgba(0,210,200,0.15);
--text: #dff4f2;
--text2: #7fb8b4;
--text3: #3d6e6a;
--accent: #00c9b8;
--accent2: #33d9ca;
--accent-glow: rgba(0,201,184,0.22);
--green: #00e5b0;
--red: #f87171;
--green-dim: rgba(74,222,128,0.15);
--red-dim: rgba(248,113,113,0.15);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3);
--shadow-md: 0 4px 16px rgba(0,0,0,0.5), 0 2px 6px rgba(0,0,0,0.3);
--shadow-lg: 0 12px 40px rgba(0,0,0,0.6), 0 4px 12px rgba(0,0,0,0.4);
--green-dim: rgba(0,229,176,0.12);
--red-dim: rgba(248,113,113,0.13);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.5), 0 1px 2px rgba(0,0,0,0.4);
--shadow-md: 0 4px 16px rgba(0,0,0,0.6), 0 2px 6px rgba(0,0,0,0.4);
--shadow-lg: 0 12px 40px rgba(0,0,0,0.7), 0 4px 12px rgba(0,0,0,0.5);
--radius: 14px;
--radius-sm: 8px;
--nav-h: 58px;
}
[data-theme="light"] {
--bg: #f0f2f8;
--bg2: #e4e8f4;
--bg3: #d8ddf0;
--surface: rgba(255,255,255,0.9);
--surface2: rgba(240,242,248,0.8);
--border: rgba(0,0,0,0.07);
--border2: rgba(0,0,0,0.12);
--text: #1a1f36;
--text2: #4a5275;
--text3: #8a92b0;
--accent: #4355e8;
--accent2: #6373f0;
--accent-glow: rgba(67,85,232,0.18);
--green: #16a34a;
--bg: #edf6f5;
--bg2: #dceeed;
--bg3: #cae5e3;
--surface: rgba(255,255,255,0.92);
--surface2: rgba(237,246,245,0.85);
--border: rgba(0,150,140,0.1);
--border2: rgba(0,150,140,0.18);
--text: #0d2422;
--text2: #2a6460;
--text3: #6aadaa;
--accent: #00897b;
--accent2: #00a896;
--accent-glow: rgba(0,137,123,0.15);
--green: #00796b;
--red: #dc2626;
--green-dim: rgba(22,163,74,0.1);
--green-dim: rgba(0,121,107,0.1);
--red-dim: rgba(220,38,38,0.1);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.05);
--shadow-md: 0 4px 16px rgba(0,0,0,0.1), 0 2px 6px rgba(0,0,0,0.06);
--shadow-lg: 0 12px 40px rgba(0,0,0,0.12), 0 4px 12px rgba(0,0,0,0.07);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 16px rgba(0,0,0,0.09), 0 2px 6px rgba(0,0,0,0.05);
--shadow-lg: 0 12px 40px rgba(0,0,0,0.11), 0 4px 12px rgba(0,0,0,0.06);
}
/* ── Reset & base ────────────────────────────────────────────────── */
@ -74,8 +74,8 @@
position: fixed;
inset: 0;
background-image:
radial-gradient(ellipse 80% 60% at 20% 10%, rgba(105,121,248,0.08) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 80% 80%, rgba(139,159,252,0.05) 0%, transparent 55%);
radial-gradient(ellipse 80% 60% at 20% 10%, rgba(0,201,184,0.07) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 80% 80%, rgba(0,150,140,0.04) 0%, transparent 55%);
pointer-events: none;
z-index: 0;
}

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>
@ -93,16 +110,17 @@ 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 = [
{{range $d.Holdings}}{ name: "{{.Name}}", value: {{.CurrentValueCents}} },{{end}}
];
].filter(h => h.value > 0);
const total = holdings.reduce((s, h) => s + h.value, 0);
if (total <= 0) return;
if (total > 0) {
const container = document.getElementById('allocation3d');
const W = container.clientWidth, H = 380;
@ -211,6 +229,8 @@ window.addEventListener('resize', () => {
camera.updateProjectionMatrix();
renderer.setSize(w2, H);
});
} // end if (total > 0)
</script>
{{else}}

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