Add account balance anchors
This commit is contained in:
+60
-8
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user