homelab/apps/finance/services/api/main/portfolio_test.go
Gonçalo Rodrigues c255d7f523 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>
2026-06-13 12:47:03 +01:00

208 lines
6.0 KiB
Go

package main
import (
"testing"
"time"
)
func mustTime(s string) time.Time {
t, _ := time.Parse("2006-01-02", s)
return t
}
func TestComputeHoldings(t *testing.T) {
tests := []struct {
name string
trades []Trade
prices map[string]int64
want []Holding
}{
{
name: "single buy",
trades: []Trade{
{ISIN: "IE00B0M62X35", Name: "ETF A", Type: "buy", Quantity: 10, PriceCents: 5000, TotalCents: 50000, Date: mustTime("2024-01-01")},
},
prices: map[string]int64{"IE00B0M62X35": 5500},
want: []Holding{
{ISIN: "IE00B0M62X35", Name: "ETF A", SharesOwned: 10, AvgEntryCents: 5000, TotalCostCents: 50000, CurrentPriceCents: 5500, CurrentValueCents: 5500, UnrealizedPCLCents: -44500, UnrealizedPCLPct: -89},
},
},
{
name: "buy then sell partial",
trades: []Trade{
{ISIN: "IE00B0M62X35", Name: "ETF A", Type: "buy", Quantity: 10, TotalCents: 50000, Date: mustTime("2024-01-01")},
{ISIN: "IE00B0M62X35", Name: "ETF A", Type: "sell", Quantity: 3, TotalCents: 18000, Date: mustTime("2024-06-01")},
},
prices: map[string]int64{"IE00B0M62X35": 5500},
want: []Holding{
{ISIN: "IE00B0M62X35", Name: "ETF A", SharesOwned: 7, AvgEntryCents: 5000, TotalCostCents: 35000, CurrentPriceCents: 5500, CurrentValueCents: 3850, UnrealizedPCLCents: -31150, UnrealizedPCLPct: -89},
},
},
{
name: "no trades",
trades: nil,
prices: map[string]int64{},
want: nil,
},
{
name: "all sold",
trades: []Trade{
{ISIN: "IE00B0M62X35", Name: "ETF A", Type: "buy", Quantity: 10, TotalCents: 50000, Date: mustTime("2024-01-01")},
{ISIN: "IE00B0M62X35", Name: "ETF A", Type: "sell", Quantity: 10, TotalCents: 55000, Date: mustTime("2024-06-01")},
},
prices: map[string]int64{},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := computeHoldings(tt.trades, tt.prices)
if len(got) != len(tt.want) {
t.Fatalf("got %d holdings, want %d\ngot: %+v", len(got), len(tt.want), got)
}
for i := range got {
h := got[i]
w := tt.want[i]
if h.ISIN != w.ISIN || h.Name != w.Name || h.SharesOwned != w.SharesOwned {
t.Errorf("holding %d = {ISIN:%q Name:%q Shares:%f}, want {ISIN:%q Name:%q Shares:%f}",
i, h.ISIN, h.Name, h.SharesOwned, w.ISIN, w.Name, w.SharesOwned)
}
}
})
}
}
func TestAggregatePortfolio(t *testing.T) {
tests := []struct {
name string
holdings []Holding
want PortfolioResult
}{
{
name: "multiple holdings sorted by value",
holdings: []Holding{
{TotalCostCents: 10000, CurrentValueCents: 15000},
{TotalCostCents: 5000, CurrentValueCents: 2000},
{TotalCostCents: 20000, CurrentValueCents: 25000},
},
want: PortfolioResult{
Holdings: []Holding{{TotalCostCents: 20000, CurrentValueCents: 25000}, {TotalCostCents: 10000, CurrentValueCents: 15000}, {TotalCostCents: 5000, CurrentValueCents: 2000}},
TotalCost: 35000,
TotalVal: 42000,
TotalPCL: 7000,
PCLPct: 20,
},
},
{
name: "empty holdings",
holdings: nil,
want: PortfolioResult{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := aggregatePortfolio(tt.holdings)
if got.TotalCost != tt.want.TotalCost {
t.Errorf("TotalCost = %d, want %d", got.TotalCost, tt.want.TotalCost)
}
if got.TotalVal != tt.want.TotalVal {
t.Errorf("TotalVal = %d, want %d", got.TotalVal, tt.want.TotalVal)
}
if got.TotalPCL != tt.want.TotalPCL {
t.Errorf("TotalPCL = %d, want %d", got.TotalPCL, tt.want.TotalPCL)
}
if len(got.Holdings) != len(tt.want.Holdings) {
t.Fatalf("got %d holdings, want %d", len(got.Holdings), len(tt.want.Holdings))
}
for i := range got.Holdings {
if got.Holdings[i].CurrentValueCents != tt.want.Holdings[i].CurrentValueCents {
t.Errorf("holding[%d].CurrentValueCents = %d, want %d", i, got.Holdings[i].CurrentValueCents, tt.want.Holdings[i].CurrentValueCents)
}
}
})
}
}
func TestHoldingsByISIN(t *testing.T) {
trades := []Trade{
{ISIN: "IE00B0M62X35"},
{ISIN: "US0378331005"},
{ISIN: "IE00B0M62X35"},
{ISIN: "US0378331005"},
{ISIN: "IE00B0M62X35"},
}
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)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestTopMerchants(t *testing.T) {
txns := []Transaction{
{Description: "Supermercado", AmountCents: -5000},
{Description: "Supermercado", AmountCents: -3000},
{Description: "Restaurante", AmountCents: -1500},
{Description: "Uber", AmountCents: -800},
{Description: "Uber", AmountCents: -1200},
}
tests := []struct {
name string
limit int
want int
first string
}{
{"limit 2", 2, 2, "Supermercado"},
{"limit 10", 10, 3, "Supermercado"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := topMerchants(txns, tt.limit)
if len(got) != tt.want {
t.Errorf("len = %d, want %d", len(got), tt.want)
}
if len(got) > 0 && got[0].Category != tt.first {
t.Errorf("first = %q, want %q", got[0].Category, tt.first)
}
})
}
}
func TestAutoCategorize(t *testing.T) {
tests := []struct {
desc string
catMap map[string]string
want string
}{
{"supermercado pingodoce", nil, "Groceries"},
{"uber trip", nil, "Transport"},
{"steam purchase", nil, "Games"},
{"renda casa", nil, "Housing"},
{"edp eletricidade", nil, "Utilities"},
{"mcdonald lunch", nil, "Food"},
{"zara clothing", nil, "Clothing"},
{"salario mensal", nil, "Income"},
{"trade republic deposit", nil, "Investments"},
{"farmacia benfica", nil, "Health"},
{"unknown merchant", nil, "Others"},
{"unknown merchant", map[string]string{"other": "Others"}, "Others"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
got := autoCategorize(tt.desc, tt.catMap)
if got != tt.want {
t.Errorf("autoCategorize(%q) = %q, want %q", tt.desc, got, tt.want)
}
})
}
}