494 lines
19 KiB
Go
494 lines
19 KiB
Go
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 — plus, when the account carries a
|
|
// balance anchor, the derived start balance. Without an anchor 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
|
|
// anchored accounts carry the bank's booked balance on anchorDate.
|
|
// residual is that figure less every movement booked through the
|
|
// anchor day: the money from before the recorded history, and the
|
|
// account's derived start balance.
|
|
anchored bool
|
|
anchorDate string
|
|
residual 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]
|
|
}
|
|
// An anchored account's balance is the bank's own figure plus what moved
|
|
// after the anchor day. The residue is order-independent, so it is settled
|
|
// before the chronological pass that judges running balances.
|
|
for _, account := range data.Accounts {
|
|
if account.AnchorDate == "" {
|
|
continue
|
|
}
|
|
anchor, err := account.AnchorBalance.Minor()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
st := state(account.ID)
|
|
st.anchored, st.anchorDate, st.residual = true, account.AnchorDate, anchor
|
|
for _, t := range data.Transactions {
|
|
if t.Facts.AccountID != account.ID || t.Facts.BookingDate > account.AnchorDate {
|
|
continue
|
|
}
|
|
if minor, e := t.Facts.Amount.Minor(); e == nil {
|
|
st.residual -= minor
|
|
}
|
|
}
|
|
}
|
|
// 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.
|
|
// Days on or before an anchor are not judged at all: the history before
|
|
// the anchor is incomplete by definition, so a running balance there is
|
|
// not observable.
|
|
closeDay := func(st *accountState) {
|
|
if !st.anchored || st.day > st.anchorDate {
|
|
if effective := st.cash + st.residual; effective < st.lowestCash {
|
|
st.lowestCash, st.lowestCashDate = effective, 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
|
|
}
|
|
cash := st.cash + st.residual
|
|
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(cash), Flows: []WealthFlow{},
|
|
Holdings: []WealthHolding{}, Checks: []WealthCheck{},
|
|
}
|
|
// The start balance reads first, like the carried-over line on a paper
|
|
// statement, and keeps the invariant that the flows sum to the balance.
|
|
if st.anchored {
|
|
entry.Flows = append(entry.Flows, WealthFlow{
|
|
Event: "anchor", Label: "Start balance (before the recorded rows)",
|
|
Cash: domain.FormatMoney(st.residual),
|
|
})
|
|
}
|
|
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] += 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(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.anchored {
|
|
check("Balance anchored", fmt.Sprintf("cash is the bank's own booked balance %s on %s plus every movement after that day; the start balance line, %s, is that figure less the movements booked through it", account.AnchorBalance, st.anchorDate, domain.FormatMoney(st.residual)), false)
|
|
} else if !account.Investing() && account.ExternalAccountID != "" {
|
|
check("Balance not anchored", "cash is the recorded movements only; the next successful synchronization captures the bank's booked balance and fixes the start balance", 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 if st.anchored {
|
|
check("Cash never negative", "the running balance stays at or above zero from the anchor day onward; earlier days are not judged against an incomplete window", false)
|
|
} 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
|
|
}
|