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>
269 lines
6.2 KiB
Go
269 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
type TickerMapping struct {
|
|
ISIN string `bson:"_id" json:"isin"`
|
|
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 {
|
|
Meta struct {
|
|
RegularMarketPrice float64 `json:"regularMarketPrice"`
|
|
} `json:"meta"`
|
|
} `json:"result"`
|
|
} `json:"chart"`
|
|
}
|
|
|
|
func computeHoldings(trades []Trade, prices map[string]int64) []Holding {
|
|
type acc struct {
|
|
shares float64
|
|
cost int64
|
|
realizedPCL int64
|
|
}
|
|
|
|
byISIN := make(map[string]*acc)
|
|
var isinOrder []string
|
|
|
|
for _, t := range trades {
|
|
a, ok := byISIN[t.ISIN]
|
|
if !ok {
|
|
a = &acc{}
|
|
byISIN[t.ISIN] = a
|
|
isinOrder = append(isinOrder, t.ISIN)
|
|
}
|
|
|
|
if t.Type == "buy" || t.Type == "Buy" {
|
|
a.shares += t.Quantity
|
|
a.cost += t.TotalCents
|
|
} else {
|
|
if a.shares > 0 {
|
|
avgCost := int64(float64(a.cost) / a.shares * t.Quantity)
|
|
a.realizedPCL += t.TotalCents - avgCost
|
|
}
|
|
a.shares -= t.Quantity
|
|
if a.shares < 0 {
|
|
a.shares = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
var holdings []Holding
|
|
for _, isin := range isinOrder {
|
|
a := byISIN[isin]
|
|
if a.shares < 0.0001 {
|
|
continue
|
|
}
|
|
|
|
var name string
|
|
for _, t := range trades {
|
|
if t.ISIN == isin {
|
|
name = t.Name
|
|
break
|
|
}
|
|
}
|
|
|
|
avgEntry := int64(0)
|
|
if a.shares > 0 {
|
|
avgEntry = int64(float64(a.cost) / a.shares)
|
|
}
|
|
|
|
currentPrice := prices[isin]
|
|
|
|
currentValue := int64(float64(currentPrice) * a.shares / 100)
|
|
unrealizedPCL := currentValue - a.cost
|
|
pct := 0.0
|
|
if a.cost > 0 {
|
|
pct = (float64(unrealizedPCL) / float64(a.cost)) * 100
|
|
}
|
|
|
|
holdings = append(holdings, Holding{
|
|
ISIN: isin,
|
|
Name: name,
|
|
SharesOwned: math.Round(a.shares*10000) / 10000,
|
|
AvgEntryCents: avgEntry,
|
|
TotalCostCents: a.cost,
|
|
CurrentPriceCents: currentPrice,
|
|
CurrentValueCents: currentValue,
|
|
UnrealizedPCLCents: unrealizedPCL,
|
|
UnrealizedPCLPct: math.Round(pct*100) / 100,
|
|
})
|
|
}
|
|
|
|
return holdings
|
|
}
|
|
|
|
type tickerStore struct {
|
|
mappings []TickerMapping
|
|
}
|
|
|
|
func (s *tickerStore) resolve(isin string) string {
|
|
for _, m := range s.mappings {
|
|
if m.ISIN == isin {
|
|
return m.Ticker
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var defaultTickerMappings = []TickerMapping{}
|
|
|
|
type TickerStore interface {
|
|
Resolve(isin string) string
|
|
Save(isin, ticker string) error
|
|
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) {
|
|
if len(isins) == 0 {
|
|
return map[string]int64{}, nil
|
|
}
|
|
|
|
result := make(map[string]int64)
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
|
|
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)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var chart yahooChartResponse
|
|
if err := json.Unmarshal(body, &chart); err != nil {
|
|
continue
|
|
}
|
|
|
|
if len(chart.Chart.Result) > 0 {
|
|
price := chart.Chart.Result[0].Meta.RegularMarketPrice
|
|
if price > 0 {
|
|
result[isin] = int64(price * 100)
|
|
slog.Info("price fetched", "isin", isin, "ticker", ticker, "price_cents", result[isin])
|
|
}
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func uniqueISINs(trades []Trade) []string {
|
|
seen := make(map[string]bool)
|
|
var result []string
|
|
for _, t := range trades {
|
|
if !seen[t.ISIN] {
|
|
seen[t.ISIN] = true
|
|
result = append(result, t.ISIN)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
type PortfolioResult struct {
|
|
Holdings []Holding
|
|
TotalCost int64
|
|
TotalVal int64
|
|
TotalPCL int64
|
|
PCLPct float64
|
|
}
|
|
|
|
func aggregatePortfolio(holdings []Holding) PortfolioResult {
|
|
var pr PortfolioResult
|
|
for _, h := range holdings {
|
|
pr.Holdings = append(pr.Holdings, h)
|
|
pr.TotalCost += h.TotalCostCents
|
|
pr.TotalVal += h.CurrentValueCents
|
|
}
|
|
pr.TotalPCL = pr.TotalVal - pr.TotalCost
|
|
if pr.TotalCost > 0 {
|
|
pr.PCLPct = math.Round(float64(pr.TotalPCL)/float64(pr.TotalCost)*10000) / 100
|
|
}
|
|
sort.Slice(pr.Holdings, func(i, j int) bool {
|
|
return pr.Holdings[i].CurrentValueCents > pr.Holdings[j].CurrentValueCents
|
|
})
|
|
return pr
|
|
}
|
|
|
|
type CategorySummary struct {
|
|
Category string
|
|
Cents int64
|
|
Count int
|
|
}
|
|
|
|
func topMerchants(transactions []Transaction, limit int) []CategorySummary {
|
|
type acc struct {
|
|
cents int64
|
|
count int
|
|
}
|
|
byDesc := make(map[string]*acc)
|
|
for _, t := range transactions {
|
|
a, ok := byDesc[t.Description]
|
|
if !ok {
|
|
a = &acc{}
|
|
byDesc[t.Description] = a
|
|
}
|
|
a.cents += t.AmountCents
|
|
a.count++
|
|
}
|
|
|
|
var result []CategorySummary
|
|
for desc, a := range byDesc {
|
|
result = append(result, CategorySummary{Category: desc, Cents: a.cents, Count: a.count})
|
|
}
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].Cents < result[j].Cents
|
|
})
|
|
if len(result) > limit {
|
|
result = result[:limit]
|
|
}
|
|
return result
|
|
}
|