Skip to content

feat(dashboard): make balance discrepancies diagnosable and chart external cash flow - #37

Merged
nyg merged 3 commits into
masterfrom
nyg/dashboard-diagnostics-and-cashflow
Aug 22, 2026
Merged

feat(dashboard): make balance discrepancies diagnosable and chart external cash flow#37
nyg merged 3 commits into
masterfrom
nyg/dashboard-diagnostics-and-cashflow

Conversation

@nyg

@nyg nyg commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Driven by a real statement whose Parse health panel reported a balance discrepancy in terms nobody could act on. Six changes.

The discrepancy, and why it was not a bug

line 5: Achat 37 UBS Core MSCI Japan UCITS ETF hCHF acc@41,305 CHF (LU1169821888)
        Mouvements  -1528,28      Solde  26755,55
        previous balance          28283,84

37 × 41,305 = 1528,285 — exactly on the half centime. DEGIRO truncated it to -1528,28 in the Mouvements column but applied -1528,29 to the Solde column. The statement's own two columns describe the same purchase one centime apart. Nothing was parsed wrongly, and the old panel had no way to say so.

Reporting that as a failure trains people to ignore the panel; hiding it leaves a real number unexplained. So reconcileBalances now classifies each gap: no wider than roundingTolerance (default 0.01) → rounding, which leaves ok true and surfaces as an informational note; anything wider → unexplained, which is what the warning banner is for. report.exact still answers the strict question, and roundingTolerance: 0 restores the old behaviour.

Diagnostics that are actually sufficient

The old blob said balances reconcile: no and discrepancies: 1. Nobody can fix that. Every discrepancy now carries the whole transition — both line numbers, the previous balance, what the amount column claimed, what the balance column actually applied, and exactAmount (quantity × unit price) when the row is a trade. That last field is what separates a misclassified row from a statement quirk.

The health panel renders it as a block rather than five truncated columns, and Copy diagnostics appends it verbatim:

  1. CHF line 5 — rounding
     classified as     buy
     description       Achat 37 UBS Core MSCI Japan UCITS ETF hCHF acc@41,305 CHF (LU1169821888)
     previous row      line 6, balance 28283.84 CHF
     amount column     -1528.28 CHF
     balance moved by  -1528.29 CHF
     quantity × price  -1528.285 CHF
     expected balance  26755.56 CHF
     reported balance  26755.55 CHF
     difference        -0.01 CHF
     reading           The row's own quantity × unit price is exactly -1528.285 CHF. The statement
                       rounded that to -1528.28 CHF in its amount column but moved the balance by
                       -1528.29 CHF. Both columns come from the statement; neither is libdegiro's.

Privacy trade-off, deliberate. The summary stays counts and unrecognised description text only. The discrepancy blocks do carry amounts, ISINs and product names — for those rows only — because a balance that does not reconcile is the arithmetic. The card says so above the button, so the choice stays the user's.

What the Fees column represents

It is every DEGIRO transaction fee booked against that instrument over the statement — the per-order charge on each buy and each sell, added up. A cost already paid, not a valuation. The header now says Transaction fees and carries that sentence.

It shows CHF and EUR because DEGIRO charges the fee in the currency of the exchange's home market, which is routinely not the currency the trade settled in: a Swiss-listed ETF bought in CHF is charged in EUR. No statement carries an exchange rate, so netting them would be inventing a number. Rows with two currencies now say two currencies with that explanation, rather than leaving the reader to guess.

The two tables also differ in how fees relate to P/L — netted on closed positions, part of the cost basis on open ones — which the card descriptions now state.

Removed captions

"6 instruments still held" and "4 instruments fully sold down" are gone. The count is already the row count; the space now explains what each table is.

ISIN links

Both Positions and Dividends by instrument link every ISIN to justETF.

Chosen because a DEGIRO statement is overwhelmingly ETFs and justETF is the only free ISIN-addressable profile showing holdings, TER and domicile without an account. It has no page for an ordinary share, so the link is marked as a lookup rather than dressed up as the instrument's home page. Happy to swap it — OpenFIGI resolves any ISIN but shows identifiers rather than anything a holder wants to read.

Navigation, not a request: connect-src 'none' is untouched and nothing leaves the tab until the link is clicked.

Deposits tab

Money crossing the account boundary, per currency: cumulative net funding as a step line, per-transfer bars, Deposited / Withdrawn / Net funded stat cards, and a table of every transfer with its running total.

Two decisions worth stating:

  • Direction comes from the sign, not the description. DEGIRO books a withdrawal as a negative Versement de fonds at least as often as it names it Retrait de fonds. A new withdrawal movement kind classifies the ones it does name, and summarizePortfolio gains withdrawals and netExternalFlow; but the split is by sign, so a negative deposit lands in the right bucket either way.
  • Internal sweeps are excluded. Transfers to and from the flatexDEGIRO cash account move money between two accounts you own and net to nothing. Including them would drown nine real deposits in fifty internal transfers.

Public API

ReconciliationReport gains exact, rounding and unexplained, and ok now means "no unexplained gaps" rather than "no gaps". BalanceDiscrepancy gains kind, previousLine, movementKind, previousBalance, statedMutation, appliedMutation and exactAmount. MovementKind gains withdrawal. PortfolioSummary gains withdrawals and netExternalFlow. New exports: withdrawalMatcher, externalFlows, DiscrepancyKind, WithdrawalMovement. README updated.

Checks

pnpm lint && pnpm typecheck && pnpm test clean, plus dashboard:typecheck, dashboard:test and dashboard:build. 86 library tests, 72 dashboard tests. New coverage for rounding classification, the transition payload, exactAmount on a synthetic half-centime row, sign-based flow splitting and per-currency running totals.

Verified in the running app against the real statement (ok now true, with an accurate note in place of the false alarm) and against a fixture perturbed to produce both a rounding gap and an unexplained one.

🤖 Generated with Claude Code

nyg and others added 3 commits August 22, 2026 20:48
…ernal cash flow

Six changes, driven by a real statement whose Parse health panel reported a
balance discrepancy in terms nobody could act on.

Balance reconciliation. A statement rounds 37 x 41,305 = 1528,285 down to
-1528,28 in its Mouvements column and up to -1528,29 in its Solde column, so its
own two columns describe the same purchase one centime apart. That is the
statement disagreeing with itself, not the parser getting anything wrong, and
reporting it as a failure trains people to ignore the panel. Gaps no wider than
roundingTolerance (default 0.01) are now classified `rounding` and leave `ok`
true; `exact` answers the strict question.

Diagnostics. Every discrepancy carries the whole transition -- both line
numbers, the previous balance, what the amount column claimed, what the balance
column actually applied, and quantity x unit price when the row is a trade. The
health panel renders that as a block rather than five truncated columns, and the
copy-diagnostics blob appends it verbatim. Amounts, ISINs and product names
appear for those rows only, because a report saying "one transition is off"
cannot be acted on; the panel says so before the button.

Positions. The Fees column is now Transaction fees with the method behind it,
and a row carrying two currencies explains why: DEGIRO charges in the currency
of the exchange's home market, which is routinely not the currency the trade
settled in, and no statement carries a rate to bridge them. The "6 instruments
still held" captions are gone, replaced by what each table actually means.

ISINs link out to justETF, in both Positions and Dividends by instrument. It is
the only free ISIN-addressable profile that needs no account; it has no page for
an ordinary share, so the link is presented as a lookup. Navigation, not a
request -- connect-src 'none' is untouched.

Deposits tab. Money crossing the account boundary, per currency: cumulative net
funding, per-transfer bars, and a table. Direction comes from the sign rather
than the description, since DEGIRO books a withdrawal as a negative "Versement
de fonds" at least as often as it names it one, and a `withdrawal` movement kind
now classifies the ones it does name. Internal sweeps to the flatexDEGIRO cash
account are excluded -- they move money between two accounts you own and would
drown nine real deposits in fifty transfers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, merge the deposit charts

The two-currency fee tooltip was wrong. It claimed DEGIRO charges in the
currency of the exchange's home market. The statement says otherwise: fees were
booked in CHF until Dec 2025, in EUR from Jan 2026, and in CHF again from Jul
2026 — for every instrument at once. The currency follows the date the fee was
charged, not the instrument, the venue or the trade's settlement currency, and
an instrument traded on both sides of a change carries both.

Saying that in the abstract still leaves "where does the 3 EUR come from"
unanswered, so each row now lists its own periods: what was charged in each
currency, over how many charges, between which dates.

ISINs no longer all point at justETF. It has no page for an ordinary share, and
Börse Frankfurt's `/equity/{isin}` resolves any listed share but addresses funds
by slug — the two are complementary, and an ISIN carries no instrument type, so
the product name decides which one the link goes to. The title names the
destination either way.

The Deposits view is one chart rather than two: bars for each transfer against
the left axis, the running total as a step line against the right, and a single
tooltip carrying both. Bars are drawn through a fixed-width shape because
`barSize` is clamped to the smallest gap between two points on a time axis,
which for deposits arriving in bursts across two years came out under three
pixels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The overview opened on a cash balance and counted positions without ever saying
what they cost. It now reads as the three questions in order: what went in, what
it bought, what is left uninvested.

"Deposited" is net of withdrawals rather than gross. "Positions" leads with the
cost basis of the shares still held and demotes the counts to the hint beneath
it. That figure needed the library: `computeOpenCost` walks the same FIFO lots
as the realized calculation and reports what the lots no sale consumed were
bought for. One walk now serves both, so the two can never disagree about which
lots a sell took. It is a cost, not a valuation — nothing in a statement carries
a current price — and it stays `null` under exactly the conditions realized P/L
does.

Trimmed the copy that was explaining rather than showing: both position table
descriptions, the fee column's second sentence, the two-currency affordance and
its tooltip, and the two descriptions under the deposits view. The fee periods
that fed the tooltip go with it.

The deposits chart drops its bars. They encoded one number per transfer that the
tooltip now gives exactly, and against a time axis they needed a second y axis
and a fixed-width shape to be legible at all — three mechanisms serving what a
hover already answers. What is left is the running total, on one axis.

Chart tooltips gap their label away from their value. `justify-between` alone
let a long series name butt straight into the figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nyg
nyg merged commit b4ff633 into master Aug 22, 2026
4 checks passed
@nyg
nyg deleted the nyg/dashboard-diagnostics-and-cashflow branch August 22, 2026 21:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant