Add account balance anchors

This commit is contained in:
Lars Nolden
2026-09-14 13:32:18 +02:00
parent 83bb86bc93
commit 71e95917da
15 changed files with 440 additions and 23 deletions
+60 -8
View File
@@ -49,9 +49,11 @@ type WealthAccount struct {
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 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
@@ -192,6 +194,13 @@ func WealthOf(data domain.Dataset) Wealth {
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 {
@@ -200,15 +209,42 @@ func WealthOf(data domain.Dataset) Wealth {
}
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.cash < st.lowestCash {
st.lowestCash, st.lowestCashDate = st.cash, st.day
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 {
@@ -317,13 +353,22 @@ func WealthOf(data domain.Dataset) Wealth {
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(st.cash), Flows: []WealthFlow{},
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{
@@ -333,7 +378,7 @@ func WealthOf(data domain.Dataset) Wealth {
}
}
seen(account.Currency)
totals[account.Currency] += st.cash
totals[account.Currency] += cash
positions, unpriced, stale := int64(0), 0, []string{}
for _, id := range st.order {
held := st.holdings[id]
@@ -372,7 +417,7 @@ func WealthOf(data domain.Dataset) Wealth {
}
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)
entry.Wealth = domain.FormatMoney(cash + positions)
positionTotals[account.Currency] += positions
unpricedTotals[account.Currency] += unpriced
@@ -384,8 +429,15 @@ func WealthOf(data domain.Dataset) Wealth {
} 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)
}