package app import ( "context" "fmt" "slices" "strings" "finance-duck/internal/domain" ) // Wealth is a reconciliation report, computed from the journal rather than from // the DuckDB index: it exists to be checked against the figures a bank or // broker shows on its own screen, so it must not depend on the cache that the // same journal derives. type Wealth struct { Accounts []WealthAccount `json:"accounts"` // Assets are the hand-valued possessions outside any account, echoed here // so the page that shows the total also shows what the total contains. Assets []WealthAsset `json:"assets"` // Totals is cash, position value, hand-valued assets and their sum per // currency, across every account. Totals []WealthTotal `json:"totals"` } type WealthTotal struct { Currency string `json:"currency"` Cash domain.Money `json:"cash"` // Positions is the market value of every priced holding, and Wealth the // two together. Holdings with no quote are excluded from both and counted // in Unpriced, because valuing them at cost would report a number the // journal cannot support. Positions domain.Money `json:"positions"` // Assets is the stated value of every hand-valued asset in this currency, // and Wealth is cash, positions and assets together. Assets domain.Money `json:"assets"` Wealth domain.Money `json:"wealth"` Unpriced int `json:"unpriced"` } // WealthAccount is one account's position as the journal records it. type WealthAccount struct { AccountID string `json:"account_id"` DisplayName string `json:"display_name"` Institution string `json:"institution"` Currency string `json:"currency"` Kind string `json:"kind"` Active bool `json:"active"` Records int `json:"records"` FirstBooking string `json:"first_booking,omitempty"` LastBooking string `json:"last_booking,omitempty"` // Cash is every recorded movement summed. It equals the account's real // balance only when the journal holds that account's complete history, // which a broker export does and a date-windowed bank statement does not. Cash domain.Money `json:"cash"` // Positions is the market value of every priced holding, and Wealth the two // together: the number this page exists to show. Unpriced counts the // holdings left out because no quote is known for them. Positions domain.Money `json:"positions"` Wealth domain.Money `json:"wealth"` Unpriced int `json:"unpriced"` // Flows is that balance grouped by what moved it, so a total that // disagrees with a broker's own figure localises to one class of row // instead of to the whole history. Flows []WealthFlow `json:"flows"` Holdings []WealthHolding `json:"holdings"` Checks []WealthCheck `json:"checks"` } // WealthFlow is the cash one kind of record moved, and how many of them there // were. The sum of every flow is the account's balance. type WealthFlow struct { Event string `json:"event"` Label string `json:"label"` Cash domain.Money `json:"cash"` Records int `json:"records"` } // flowLabels names each kind of movement in the order a statement reads, so // the breakdown is comparable line by line against a broker's own screen. var flowLabels = []struct{ event, label string }{ {domain.EventDeposit, "Deposits"}, {domain.EventWithdrawal, "Withdrawals"}, {domain.EventFee, "Broker fees"}, {domain.EventInterest, "Interest"}, {domain.EventTaxSettlement, "Tax settlements"}, {domain.EventDistribution, "Distributions"}, {domain.EventBuy, "Purchases"}, {domain.EventSell, "Sales"}, {domain.EventReinvest, "Reinvestments"}, {domain.EventCorporateAction, "Corporate actions"}, {domain.EventPositionTransfer, "Depot transfers"}, {"bank", "Rows from other sources"}, } // WealthHolding is one instrument's position in one account. type WealthHolding struct { InstrumentID string `json:"instrument_id"` ISIN string `json:"isin"` Name string `json:"name"` Quantity domain.Quantity `json:"quantity"` // Invested is cash paid in less cash taken out through trades. It is not a // cost basis: a depot transfer moves a position with no cash at all, and a // sale returns cash without identifying which lot it closed. Invested domain.Money `json:"invested"` // Received is cash this instrument paid out without moving the position: // distributions, and the cash side of a corporate action. Received domain.Money `json:"received"` // Quote is the last known unit price and QuotedAt the day it is from. // Value is the holding at that price. Priced is false when no quote is // known, and then Value is absent rather than guessed from cost. Quote domain.Quantity `json:"quote,omitempty"` QuotedAt string `json:"quoted_at,omitempty"` Value domain.Money `json:"value,omitempty"` Priced bool `json:"priced"` // Result is the value now plus every euro this position returned, less // every euro put into it: the total outcome to date, realised and not. Result domain.Money `json:"result,omitempty"` Records int `json:"records"` } // WealthAsset is one hand-valued asset as the journal records it. The value is // stated, never quoted, and carries the day it was stated. type WealthAsset struct { AssetID string `json:"asset_id"` Name string `json:"name"` Kind string `json:"kind,omitempty"` Currency string `json:"currency"` Value domain.Money `json:"value"` ValuedAt string `json:"valued_at"` } // WealthCheck is one named verification with its evidence. Failed marks a // disagreement inside the journal; the rest are notes that explain a figure // before it is compared with a broker's screen. type WealthCheck struct { Name string `json:"name"` Detail string `json:"detail"` Failed bool `json:"failed"` } // Wealth reports every account's cash and positions with the checks that decide // whether those figures can be trusted. func (a *App) Wealth(ctx context.Context) (Wealth, error) { s, err := a.Snapshot(ctx) if err != nil { return Wealth{}, err } return WealthOf(s.Data), nil } // WealthOf derives the report from a dataset. Money is summed in exact // ten-thousandths; 64 bits hold hundreds of trillions, far beyond any number a // journal of personal accounts can reach. func WealthOf(data domain.Dataset) Wealth { instruments := map[string]domain.Instrument{} for _, v := range data.Instruments { instruments[v.ID] = v } accounts := map[string]domain.Account{} for _, v := range data.Accounts { accounts[v.ID] = v } ordered := slices.Clone(data.Transactions) slices.SortStableFunc(ordered, func(x, y domain.Transaction) int { if c := strings.Compare(x.Facts.BookingDate, y.Facts.BookingDate); c != 0 { return c } return strings.Compare(x.Facts.ID, y.Facts.ID) }) type flowState struct { cash int64 records int } type holdingState struct { units, invested, received int64 records int lowest int64 lowestDate string } type accountState struct { cash, lowestCash int64 lowestCashDate string day string records int first, last string holdings map[string]*holdingState order []string flows map[string]*flowState broken []string unappliedFee, unappliedTax int64 unappliedRows int unmatchedCash, unmatchedRows int64 } states := map[string]*accountState{} state := func(id string) *accountState { if states[id] == nil { states[id] = &accountState{holdings: map[string]*holdingState{}, flows: map[string]*flowState{}} } return states[id] } // A day's rows are applied together before any low-water mark is taken. // Order within a day is not knowable: a broker export states a booking date // and a clock time, the time is local and crosses midnight, so only the // date is imported. A purchase funded by a sale nine seconds earlier then // arrives in an arbitrary order, and checking row by row reports a dip // that never happened. closeDay := func(st *accountState) { if st.cash < st.lowestCash { st.lowestCash, st.lowestCashDate = st.cash, st.day } for _, held := range st.holdings { if held.units < held.lowest { held.lowest, held.lowestDate = held.units, st.day } } } for _, t := range ordered { f := t.Facts account := accounts[f.AccountID] st := state(f.AccountID) if st.day != "" && st.day != f.BookingDate { closeDay(st) } st.day = f.BookingDate st.records++ if st.first == "" { st.first = f.BookingDate } st.last = f.BookingDate minor, err := f.Amount.Minor() if err != nil { st.broken = append(st.broken, fmt.Sprintf("%s: unreadable amount %q", f.BookingDate, f.Amount)) continue } st.cash += minor inv := f.Investment flow := "bank" if inv != nil { flow = inv.Event } if st.flows[flow] == nil { st.flows[flow] = &flowState{} } st.flows[flow].cash += minor st.flows[flow].records++ if inv == nil { continue } if err := domain.ValidateInvestment(f, account, instruments); err != nil { st.broken = append(st.broken, fmt.Sprintf("%s %s: %v", f.BookingDate, f.ID, err)) } if inv.CashOnly() { // A cash row carrying a gross had its fee and tax applied to reach // that amount, and its settlement is already verified above. Only a // row whose amount arrived net has figures that were recorded and // deliberately never subtracted. fee, _ := inv.Fee.Minor() tax, _ := inv.Tax.Minor() if inv.Gross == "" && (fee != 0 || tax != 0) { st.unappliedRows++ st.unappliedFee += fee st.unappliedTax += tax } if (inv.Event == domain.EventDeposit || inv.Event == domain.EventWithdrawal) && t.Enrichment.Kind != "transfer" { st.unmatchedRows++ st.unmatchedCash += minor } } if inv.InstrumentID == "" { continue } held := st.holdings[inv.InstrumentID] if held == nil { held = &holdingState{} st.holdings[inv.InstrumentID] = held st.order = append(st.order, inv.InstrumentID) } held.records++ if inv.Settling() { held.invested -= minor } else { held.received += minor } units, err := inv.Quantity.Units() if inv.Quantity == "" { units, err = 0, nil } if err != nil { st.broken = append(st.broken, fmt.Sprintf("%s %s: unreadable quantity %q", f.BookingDate, f.ID, inv.Quantity)) continue } held.units += units } for _, st := range states { if st.day != "" { closeDay(st) } } report := Wealth{Accounts: []WealthAccount{}, Assets: []WealthAsset{}, Totals: []WealthTotal{}} totals := map[string]int64{} positionTotals := map[string]int64{} assetTotals := map[string]int64{} unpricedTotals := map[string]int{} currencies := []string{} seen := func(currency string) { if _, ok := totals[currency]; !ok { currencies = append(currencies, currency) totals[currency] = 0 } } for _, account := range data.Accounts { st := state(account.ID) kind := account.Kind if kind == "" { kind = domain.AccountCash } entry := WealthAccount{ AccountID: account.ID, DisplayName: account.DisplayName, Institution: account.Institution, Currency: account.Currency, Kind: kind, Active: account.Active, Records: st.records, FirstBooking: st.first, LastBooking: st.last, Cash: domain.FormatMoney(st.cash), Flows: []WealthFlow{}, Holdings: []WealthHolding{}, Checks: []WealthCheck{}, } for _, flow := range flowLabels { if moved := st.flows[flow.event]; moved != nil { entry.Flows = append(entry.Flows, WealthFlow{ Event: flow.event, Label: flow.label, Cash: domain.FormatMoney(moved.cash), Records: moved.records, }) } } seen(account.Currency) totals[account.Currency] += st.cash positions, unpriced, stale := int64(0), 0, []string{} for _, id := range st.order { held := st.holdings[id] instrument := instruments[id] holding := WealthHolding{ InstrumentID: id, ISIN: instrument.ISIN, Name: instrument.Name, Quantity: domain.FormatQuantity(held.units), Invested: domain.FormatMoney(held.invested), Received: domain.FormatMoney(held.received), Records: held.records, } // A closed position needs no quote: nothing multiplied by any price // is nothing, and its result is already settled in cash. quote, err := instrument.Quote.Units() switch { case held.units == 0: holding.Priced, holding.Value = true, domain.FormatMoney(0) case instrument.Quote == "" || err != nil: unpriced++ stale = append(stale, instrument.ISIN) default: value, ok := domain.RoundedProduct(held.units, quote) if !ok { unpriced++ stale = append(stale, instrument.ISIN) break } holding.Priced = true holding.Quote, holding.QuotedAt = instrument.Quote, instrument.QuotedAt holding.Value = domain.FormatMoney(value) positions += value } if holding.Priced { settled, _ := holding.Value.Minor() holding.Result = domain.FormatMoney(settled - held.invested + held.received) } entry.Holdings = append(entry.Holdings, holding) } slices.SortFunc(entry.Holdings, func(x, y WealthHolding) int { return strings.Compare(x.Name, y.Name) }) entry.Positions, entry.Unpriced = domain.FormatMoney(positions), unpriced entry.Wealth = domain.FormatMoney(st.cash + positions) positionTotals[account.Currency] += positions unpricedTotals[account.Currency] += unpriced check := func(name, detail string, failed bool) { entry.Checks = append(entry.Checks, WealthCheck{Name: name, Detail: detail, Failed: failed}) } if len(st.broken) > 0 { check("Row arithmetic", fmt.Sprintf("%d record(s) disagree with their own figures: %s", len(st.broken), strings.Join(st.broken, "; ")), true) } else { check("Row arithmetic", "every record agrees with its own gross, fee, tax, quantity and price", false) } if st.lowestCash < 0 { check("Cash never negative", fmt.Sprintf("balance reached %s on %s, so the history is incomplete or a movement is misread", domain.FormatMoney(st.lowestCash), st.lowestCashDate), true) } else { check("Cash never negative", "the running balance stays at or above zero throughout", false) } negative := []string{} for _, id := range st.order { if held := st.holdings[id]; held.lowest < 0 { negative = append(negative, fmt.Sprintf("%s reached %s on %s", instruments[id].ISIN, domain.FormatQuantity(held.lowest), held.lowestDate)) } } if len(negative) > 0 { check("Holdings never negative", fmt.Sprintf("%s — a sale before its purchase means the export is partial or a sign is wrong", strings.Join(negative, "; ")), true) } else if len(st.order) > 0 { check("Holdings never negative", "every position stays at or above zero throughout", false) } if st.unappliedRows > 0 { check("Fee and tax recorded, not applied", fmt.Sprintf("%d cash record(s) carry fee %s and tax %s. A broker cash amount is already net of them, so they are recorded and not subtracted again. If the balance above is wrong by one of these figures, this is why", st.unappliedRows, domain.FormatMoney(st.unappliedFee), domain.FormatMoney(st.unappliedTax)), false) } if st.unmatchedRows > 0 { check("Deposits and withdrawals unmatched", fmt.Sprintf("%d transfer(s) totalling %s have no counterpart in another account. They stay out of spending either way; set this account's IBAN and settlement IBAN to pair them", st.unmatchedRows, domain.FormatMoney(st.unmatchedCash)), false) } if unpriced > 0 { check("Holdings priced", fmt.Sprintf("%d holding(s) have no quote and are left out of the wealth above: %s. Set each one's market symbol in Instruments so the daily price job can quote it; valuing them at cost would report a number the journal cannot support", unpriced, strings.Join(stale, ", ")), false) } else if len(st.order) > 0 { check("Holdings priced", "every open position has a quote, so the wealth above is complete", false) } report.Accounts = append(report.Accounts, entry) } // Hand-valued assets join the totals after the accounts: they belong to no // account, and a currency held only in an asset still earns its own line. for _, asset := range data.Assets { value, err := asset.Value.Minor() if err != nil { continue } seen(asset.Currency) assetTotals[asset.Currency] += value report.Assets = append(report.Assets, WealthAsset{ AssetID: asset.ID, Name: asset.Name, Kind: asset.Kind, Currency: asset.Currency, Value: domain.FormatMoney(value), ValuedAt: asset.ValuedAt, }) } slices.SortStableFunc(report.Assets, func(x, y WealthAsset) int { return strings.Compare(x.Name, y.Name) }) for _, currency := range currencies { report.Totals = append(report.Totals, WealthTotal{ Currency: currency, Cash: domain.FormatMoney(totals[currency]), Positions: domain.FormatMoney(positionTotals[currency]), Assets: domain.FormatMoney(assetTotals[currency]), Wealth: domain.FormatMoney(totals[currency] + positionTotals[currency] + assetTotals[currency]), Unpriced: unpricedTotals[currency], }) } return report }