fix(finance): portfolio prices and projections template
Portfolio: - fetchPrices was passing ISINs directly to Yahoo Finance, which only accepts ticker symbols (e.g. VWCE.DE, not IE00B3RBWM25). Added a hardcoded isinToTicker map covering common European ETFs (Vanguard, iShares, Xtrackers, Amundi). fetchPricesByISIN now resolves each ISIN to its ticker before calling Yahoo and keys the result by ISIN so computeHoldings can look up prices correctly. Renamed holdingsByISIN to uniqueISINs to reflect what it actually returns. Projections: - Template had a missing <tr> and a broken pace-bar width expression that mixed float64/int64 in the div template function, causing a template execution error that rendered the page blank. Moved all math server-side: the handler now pre-computes []catProjection with MonthlyAvg, AnnualTotal, and PacePct already calculated. Template is now simple range + printf. Chart switched to horizontal bar for better readability of long category names. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2de6e4d4a7
commit
c255d7f523
@ -933,6 +933,41 @@ func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
annualTotal := int64(math.Round(float64(-totalSpend) / float64(monthCount) * 12))
|
||||
monthlyTotal := float64(annualTotal) / 12
|
||||
|
||||
// pre-compute pace percentage per category for the template (avoids float/int type issues)
|
||||
type catProjection struct {
|
||||
Name string
|
||||
MonthlyAvg float64
|
||||
AnnualTotal float64
|
||||
PacePct int
|
||||
}
|
||||
cats2, _ := h.store.getCategories(ctx, a.UserID)
|
||||
catColors2 := make(map[string]string)
|
||||
for _, c := range cats2 {
|
||||
catColors2[c.Name] = c.Color
|
||||
}
|
||||
|
||||
var projections []catProjection
|
||||
for cat, avg := range monthlyAvg {
|
||||
pct := 0
|
||||
if monthlyTotal > 0 {
|
||||
pct = int(math.Round(avg / monthlyTotal * 100))
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
}
|
||||
projections = append(projections, catProjection{
|
||||
Name: cat,
|
||||
MonthlyAvg: avg,
|
||||
AnnualTotal: avg * 12,
|
||||
PacePct: pct,
|
||||
})
|
||||
}
|
||||
// sort by monthly avg descending
|
||||
sort.Slice(projections, func(i, j int) bool {
|
||||
return projections[i].MonthlyAvg > projections[j].MonthlyAvg
|
||||
})
|
||||
|
||||
render(w, projectionsTmpl, map[string]interface{}{
|
||||
"UserID": a.UserID,
|
||||
@ -940,9 +975,9 @@ func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) {
|
||||
"Title": "Projections",
|
||||
"Route": "projections",
|
||||
"IsOwner": true,
|
||||
"MonthlyAvg": monthlyAvg,
|
||||
"Projections": projections,
|
||||
"AnnualTotal": annualTotal,
|
||||
"CategoryNames": catNames,
|
||||
"CategoryColors": catColors2,
|
||||
})
|
||||
}
|
||||
|
||||
@ -957,8 +992,8 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tickers := holdingsByISIN(trades)
|
||||
if len(tickers) == 0 {
|
||||
isins := uniqueISINs(trades)
|
||||
if len(isins) == 0 {
|
||||
render(w, portfolioTmpl, &PortfolioData{
|
||||
UserID: a.UserID,
|
||||
Email: a.Email,
|
||||
@ -968,7 +1003,7 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
prices, err := fetchPrices(tickers)
|
||||
prices, err := fetchPricesByISIN(isins)
|
||||
if err != nil {
|
||||
slog.Error("fetch prices", "err", err)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
@ -15,6 +16,30 @@ type TickerMapping struct {
|
||||
Ticker string `bson:"ticker" json:"ticker"`
|
||||
}
|
||||
|
||||
// isinToTicker maps well-known ISIN codes to their Yahoo Finance ticker symbols.
|
||||
// Add entries here for any ETF/stock you trade that isn't covered yet.
|
||||
var isinToTicker = map[string]string{
|
||||
// Vanguard
|
||||
"IE00B3RBWM25": "VWCE.DE", // VWCE — All-World
|
||||
"IE00B3XXRP09": "VWRL.AS", // VWRL — All-World
|
||||
"IE00BK5BQT80": "VWRA.L", // VWRA — All-World (acc, LSE)
|
||||
// iShares
|
||||
"IE00B4L5Y983": "EUNL.DE", // EUNL — MSCI World
|
||||
"IE00B5BMR087": "SXR8.DE", // SXR8 — S&P 500
|
||||
"IE00B4K48X80": "SXRV.DE", // SXRV — S&P 500 EUR hedged
|
||||
"IE00B52MJY50": "IUSA.AS", // IUSA — S&P 500
|
||||
"IE00B0M63177": "IWRD.AS", // IWRD — MSCI World
|
||||
"IE00B4ND3602": "EMIM.AS", // EMIM — Emerging Markets
|
||||
"IE00BKM4GZ66": "IS3N.DE", // IS3N — Core MSCI EM
|
||||
"IE00B4L5YC18": "CSSPX.MI", // CSSPX — Core S&P 500
|
||||
// Xtrackers
|
||||
"LU0274208692": "DBXW.DE", // DBXW — MSCI World
|
||||
"LU0490618542": "XMWO.DE", // XMWO — MSCI World Swap
|
||||
// Amundi
|
||||
"LU1681043599": "CW8.PA", // CW8 — MSCI World
|
||||
"FR0010315770": "CU2.PA", // CU2 — S&P 500
|
||||
}
|
||||
|
||||
type yahooChartResponse struct {
|
||||
Chart struct {
|
||||
Result []struct {
|
||||
@ -124,21 +149,27 @@ type TickerStore interface {
|
||||
Load() error
|
||||
}
|
||||
|
||||
func fetchPrices(tickers []string) (map[string]int64, error) {
|
||||
if len(tickers) == 0 {
|
||||
// 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) {
|
||||
if len(isins) == 0 {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
|
||||
result := make(map[string]int64)
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
for _, ticker := range tickers {
|
||||
if ticker == "" {
|
||||
continue
|
||||
for _, isin := range isins {
|
||||
ticker, ok := isinToTicker[isin]
|
||||
if !ok {
|
||||
ticker = isin // last-resort fallback
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s", ticker)
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
slog.Warn("price fetch failed", "isin", isin, "ticker", ticker, "err", err)
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
@ -154,14 +185,17 @@ func fetchPrices(tickers []string) (map[string]int64, error) {
|
||||
|
||||
if len(chart.Chart.Result) > 0 {
|
||||
price := chart.Chart.Result[0].Meta.RegularMarketPrice
|
||||
result[ticker] = int64(price * 100)
|
||||
if price > 0 {
|
||||
result[isin] = int64(price * 100)
|
||||
slog.Info("price fetched", "isin", isin, "ticker", ticker, "price_cents", result[isin])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func holdingsByISIN(trades []Trade) []string {
|
||||
func uniqueISINs(trades []Trade) []string {
|
||||
seen := make(map[string]bool)
|
||||
var result []string
|
||||
for _, t := range trades {
|
||||
|
||||
@ -133,7 +133,7 @@ func TestHoldingsByISIN(t *testing.T) {
|
||||
{ISIN: "US0378331005"},
|
||||
{ISIN: "IE00B0M62X35"},
|
||||
}
|
||||
got := holdingsByISIN(trades)
|
||||
got := uniqueISINs(trades)
|
||||
want := []string{"IE00B0M62X35", "US0378331005"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d isins, want %d: %v", len(got), len(want), got)
|
||||
|
||||
@ -2,24 +2,26 @@
|
||||
{{$d := .}}
|
||||
<h1 style="margin-bottom:24px;">Projections</h1>
|
||||
|
||||
{{if $d.Projections}}
|
||||
|
||||
<div class="grid">
|
||||
<div class="card value-card animate-on-scroll">
|
||||
<h2>Projected Annual Spend</h2>
|
||||
<div class="value negative animate-counter" data-target="{{$d.AnnualTotal}}" data-prefix="€">€0.00</div>
|
||||
<p class="text-muted" style="margin-top:8px;">Based on 6-month average</p>
|
||||
<p class="text-muted" style="margin-top:8px; font-size:12px;">Based on 6-month average</p>
|
||||
</div>
|
||||
<div class="card value-card animate-on-scroll">
|
||||
<h2>Projected Monthly Spend</h2>
|
||||
{{$monthly := div $d.AnnualTotal 12}}
|
||||
<div class="value negative animate-counter" data-target="{{$monthly}}" data-prefix="€">€0.00</div>
|
||||
<p class="text-muted" style="margin-top:8px;">Average across categories</p>
|
||||
<p class="text-muted" style="margin-top:8px; font-size:12px;">Average across all categories</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card animate-on-scroll">
|
||||
<h2 style="margin-bottom:12px;">Monthly Average by Category — Last 6 Months</h2>
|
||||
<h2 style="margin-bottom:16px;">Monthly Average by Category — Last 6 Months</h2>
|
||||
<div style="padding-top:4px;">
|
||||
<canvas id="projChart" height="260"></canvas>
|
||||
<canvas id="projChart" height="250"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -31,21 +33,31 @@
|
||||
<th>Category</th>
|
||||
<th class="text-right">Monthly Avg</th>
|
||||
<th class="text-right">Projected Annual</th>
|
||||
<th style="width:200px;">Pace</th>
|
||||
<th style="width:180px;">Share of Spend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $cat, $avg := $d.MonthlyAvg}}
|
||||
<td style="font-weight:500;">{{$cat}}</td>
|
||||
<td class="cents negative">€{{printf "%.2f" $avg}}</td>
|
||||
<td class="cents negative">€{{printf "%.2f" (mul $avg 12)}}</td>
|
||||
<td>
|
||||
<div style="background:var(--bg3); border-radius:6px; height:6px; overflow:hidden;">
|
||||
<div style="height:100%; border-radius:6px; background:var(--accent);
|
||||
width:{{round (mul (div (round (mul $avg 100)) (div $d.AnnualTotal 12)) 100)}}%;
|
||||
max-width:100%;"></div>
|
||||
</div>
|
||||
</td>
|
||||
{{range $d.Projections}}
|
||||
{{$color := index $d.CategoryColors .Name}}
|
||||
<tr>
|
||||
<td>
|
||||
<span style="display:inline-flex; align-items:center; gap:7px;">
|
||||
{{if $color}}<span style="width:9px;height:9px;border-radius:50%;background:{{$color}};box-shadow:0 0 5px {{$color}}88;flex-shrink:0;"></span>{{end}}
|
||||
<span style="font-weight:500;">{{.Name}}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="cents negative">€{{printf "%.2f" .MonthlyAvg}}</td>
|
||||
<td class="cents negative">€{{printf "%.2f" .AnnualTotal}}</td>
|
||||
<td>
|
||||
<div style="display:flex; align-items:center; gap:8px;">
|
||||
<div style="flex:1; background:var(--bg3); border-radius:6px; height:7px; overflow:hidden;">
|
||||
<div style="height:100%; border-radius:6px; width:{{.PacePct}}%;
|
||||
background:{{if $color}}{{$color}}{{else}}var(--accent){{end}};
|
||||
box-shadow:{{if $color}}0 0 5px {{$color}}66{{else}}0 0 5px var(--accent-glow){{end}};"></div>
|
||||
</div>
|
||||
<span style="font-size:11.5px; color:var(--text3); min-width:30px; text-align:right;">{{.PacePct}}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
@ -58,35 +70,44 @@ const isDark = () => document.documentElement.getAttribute('data-theme') === 'da
|
||||
const gridC = () => isDark() ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)';
|
||||
const textC = () => isDark() ? '#5c6585' : '#8a92b0';
|
||||
|
||||
const catColors = [
|
||||
'#6979f8','#f87171','#fbbf24','#34d399','#a78bfa',
|
||||
'#f472b6','#38bdf8','#fb923c','#4ade80','#e879f9',
|
||||
'#94a3b8','#22d3ee','#f97316','#a3e635',
|
||||
];
|
||||
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
|
||||
const labels = [{{range $d.Projections}}"{{.Name}}",{{end}}];
|
||||
const data = [{{range $d.Projections}}{{printf "%.2f" .MonthlyAvg}},{{end}}];
|
||||
const colors = labels.map(k => catColors[k] || '#6979f8');
|
||||
|
||||
new Chart(document.getElementById('projChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [{{range $cat, $_ := $d.MonthlyAvg}}"{{$cat}}",{{end}}],
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Monthly Avg (€)',
|
||||
data: [{{range $_, $avg := $d.MonthlyAvg}}{{$avg}},{{end}}],
|
||||
backgroundColor: catColors.map(c => c + '99'),
|
||||
borderColor: catColors,
|
||||
data,
|
||||
backgroundColor: colors.map(c => c + '99'),
|
||||
borderColor: colors,
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 6,
|
||||
borderSkipped: false,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
animation: { duration: 900, easing: 'easeOutQuart' },
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } },
|
||||
x: { grid: { display: false }, ticks: { color: textC() } }
|
||||
x: { beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } },
|
||||
y: { grid: { display: false }, ticks: { color: textC() } }
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{{else}}
|
||||
<div class="card empty-state animate-on-scroll">
|
||||
<div style="font-size:48px; margin-bottom:16px;">📊</div>
|
||||
<h3>No spending data yet</h3>
|
||||
<p>Import at least a month of transactions to see projections.</p>
|
||||
<a href="/import" class="btn btn-primary" style="margin-top:20px;">Import Transactions</a>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user