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))
|
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{}{
|
render(w, projectionsTmpl, map[string]interface{}{
|
||||||
"UserID": a.UserID,
|
"UserID": a.UserID,
|
||||||
@ -940,9 +975,9 @@ func (h *Handler) Projections(w http.ResponseWriter, r *http.Request) {
|
|||||||
"Title": "Projections",
|
"Title": "Projections",
|
||||||
"Route": "projections",
|
"Route": "projections",
|
||||||
"IsOwner": true,
|
"IsOwner": true,
|
||||||
"MonthlyAvg": monthlyAvg,
|
"Projections": projections,
|
||||||
"AnnualTotal": annualTotal,
|
"AnnualTotal": annualTotal,
|
||||||
"CategoryNames": catNames,
|
"CategoryColors": catColors2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -957,8 +992,8 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tickers := holdingsByISIN(trades)
|
isins := uniqueISINs(trades)
|
||||||
if len(tickers) == 0 {
|
if len(isins) == 0 {
|
||||||
render(w, portfolioTmpl, &PortfolioData{
|
render(w, portfolioTmpl, &PortfolioData{
|
||||||
UserID: a.UserID,
|
UserID: a.UserID,
|
||||||
Email: a.Email,
|
Email: a.Email,
|
||||||
@ -968,7 +1003,7 @@ func (h *Handler) Portfolio(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prices, err := fetchPrices(tickers)
|
prices, err := fetchPricesByISIN(isins)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("fetch prices", "err", err)
|
slog.Error("fetch prices", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
@ -15,6 +16,30 @@ type TickerMapping struct {
|
|||||||
Ticker string `bson:"ticker" json:"ticker"`
|
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 {
|
type yahooChartResponse struct {
|
||||||
Chart struct {
|
Chart struct {
|
||||||
Result []struct {
|
Result []struct {
|
||||||
@ -124,21 +149,27 @@ type TickerStore interface {
|
|||||||
Load() error
|
Load() error
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchPrices(tickers []string) (map[string]int64, error) {
|
// fetchPricesByISIN resolves each ISIN to a Yahoo Finance ticker (via isinToTicker),
|
||||||
if len(tickers) == 0 {
|
// 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
|
return map[string]int64{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make(map[string]int64)
|
result := make(map[string]int64)
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
for _, ticker := range tickers {
|
for _, isin := range isins {
|
||||||
if ticker == "" {
|
ticker, ok := isinToTicker[isin]
|
||||||
continue
|
if !ok {
|
||||||
|
ticker = isin // last-resort fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s", ticker)
|
url := fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s", ticker)
|
||||||
resp, err := client.Get(url)
|
resp, err := client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Warn("price fetch failed", "isin", isin, "ticker", ticker, "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
@ -154,14 +185,17 @@ func fetchPrices(tickers []string) (map[string]int64, error) {
|
|||||||
|
|
||||||
if len(chart.Chart.Result) > 0 {
|
if len(chart.Chart.Result) > 0 {
|
||||||
price := chart.Chart.Result[0].Meta.RegularMarketPrice
|
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
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func holdingsByISIN(trades []Trade) []string {
|
func uniqueISINs(trades []Trade) []string {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var result []string
|
var result []string
|
||||||
for _, t := range trades {
|
for _, t := range trades {
|
||||||
|
|||||||
@ -133,7 +133,7 @@ func TestHoldingsByISIN(t *testing.T) {
|
|||||||
{ISIN: "US0378331005"},
|
{ISIN: "US0378331005"},
|
||||||
{ISIN: "IE00B0M62X35"},
|
{ISIN: "IE00B0M62X35"},
|
||||||
}
|
}
|
||||||
got := holdingsByISIN(trades)
|
got := uniqueISINs(trades)
|
||||||
want := []string{"IE00B0M62X35", "US0378331005"}
|
want := []string{"IE00B0M62X35", "US0378331005"}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("got %d isins, want %d: %v", len(got), len(want), got)
|
t.Fatalf("got %d isins, want %d: %v", len(got), len(want), got)
|
||||||
|
|||||||
@ -2,24 +2,26 @@
|
|||||||
{{$d := .}}
|
{{$d := .}}
|
||||||
<h1 style="margin-bottom:24px;">Projections</h1>
|
<h1 style="margin-bottom:24px;">Projections</h1>
|
||||||
|
|
||||||
|
{{if $d.Projections}}
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Projected Annual Spend</h2>
|
<h2>Projected Annual Spend</h2>
|
||||||
<div class="value negative animate-counter" data-target="{{$d.AnnualTotal}}" data-prefix="€">€0.00</div>
|
<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>
|
||||||
<div class="card value-card animate-on-scroll">
|
<div class="card value-card animate-on-scroll">
|
||||||
<h2>Projected Monthly Spend</h2>
|
<h2>Projected Monthly Spend</h2>
|
||||||
{{$monthly := div $d.AnnualTotal 12}}
|
{{$monthly := div $d.AnnualTotal 12}}
|
||||||
<div class="value negative animate-counter" data-target="{{$monthly}}" data-prefix="€">€0.00</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="card animate-on-scroll">
|
<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;">
|
<div style="padding-top:4px;">
|
||||||
<canvas id="projChart" height="260"></canvas>
|
<canvas id="projChart" height="250"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -31,21 +33,31 @@
|
|||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th class="text-right">Monthly Avg</th>
|
<th class="text-right">Monthly Avg</th>
|
||||||
<th class="text-right">Projected Annual</th>
|
<th class="text-right">Projected Annual</th>
|
||||||
<th style="width:200px;">Pace</th>
|
<th style="width:180px;">Share of Spend</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range $cat, $avg := $d.MonthlyAvg}}
|
{{range $d.Projections}}
|
||||||
<td style="font-weight:500;">{{$cat}}</td>
|
{{$color := index $d.CategoryColors .Name}}
|
||||||
<td class="cents negative">€{{printf "%.2f" $avg}}</td>
|
<tr>
|
||||||
<td class="cents negative">€{{printf "%.2f" (mul $avg 12)}}</td>
|
<td>
|
||||||
<td>
|
<span style="display:inline-flex; align-items:center; gap:7px;">
|
||||||
<div style="background:var(--bg3); border-radius:6px; height:6px; overflow:hidden;">
|
{{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}}
|
||||||
<div style="height:100%; border-radius:6px; background:var(--accent);
|
<span style="font-weight:500;">{{.Name}}</span>
|
||||||
width:{{round (mul (div (round (mul $avg 100)) (div $d.AnnualTotal 12)) 100)}}%;
|
</span>
|
||||||
max-width:100%;"></div>
|
</td>
|
||||||
</div>
|
<td class="cents negative">€{{printf "%.2f" .MonthlyAvg}}</td>
|
||||||
</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>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</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 gridC = () => isDark() ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)';
|
||||||
const textC = () => isDark() ? '#5c6585' : '#8a92b0';
|
const textC = () => isDark() ? '#5c6585' : '#8a92b0';
|
||||||
|
|
||||||
const catColors = [
|
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
|
||||||
'#6979f8','#f87171','#fbbf24','#34d399','#a78bfa',
|
const labels = [{{range $d.Projections}}"{{.Name}}",{{end}}];
|
||||||
'#f472b6','#38bdf8','#fb923c','#4ade80','#e879f9',
|
const data = [{{range $d.Projections}}{{printf "%.2f" .MonthlyAvg}},{{end}}];
|
||||||
'#94a3b8','#22d3ee','#f97316','#a3e635',
|
const colors = labels.map(k => catColors[k] || '#6979f8');
|
||||||
];
|
|
||||||
|
|
||||||
new Chart(document.getElementById('projChart'), {
|
new Chart(document.getElementById('projChart'), {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: [{{range $cat, $_ := $d.MonthlyAvg}}"{{$cat}}",{{end}}],
|
labels,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Monthly Avg (€)',
|
label: 'Monthly Avg (€)',
|
||||||
data: [{{range $_, $avg := $d.MonthlyAvg}}{{$avg}},{{end}}],
|
data,
|
||||||
backgroundColor: catColors.map(c => c + '99'),
|
backgroundColor: colors.map(c => c + '99'),
|
||||||
borderColor: catColors,
|
borderColor: colors,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
borderSkipped: false,
|
borderSkipped: false,
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
responsive: true,
|
responsive: true,
|
||||||
animation: { duration: 900, easing: 'easeOutQuart' },
|
animation: { duration: 900, easing: 'easeOutQuart' },
|
||||||
plugins: { legend: { display: false } },
|
plugins: { legend: { display: false } },
|
||||||
scales: {
|
scales: {
|
||||||
y: { beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } },
|
x: { beginAtZero: true, grid: { color: gridC() }, ticks: { color: textC(), callback: v => '€' + v } },
|
||||||
x: { grid: { display: false }, ticks: { color: textC() } }
|
y: { grid: { display: false }, ticks: { color: textC() } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</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}}
|
{{end}}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user