feat(dashboard): make balance discrepancies diagnosable and chart external cash flow - #37
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
37 × 41,305 = 1528,285— exactly on the half centime. DEGIRO truncated it to-1528,28in the Mouvements column but applied-1528,29to 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
reconcileBalancesnow classifies each gap: no wider thanroundingTolerance(default0.01) →rounding, which leavesoktrue and surfaces as an informational note; anything wider →unexplained, which is what the warning banner is for.report.exactstill answers the strict question, androundingTolerance: 0restores the old behaviour.Diagnostics that are actually sufficient
The old blob said
balances reconcile: noanddiscrepancies: 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, andexactAmount(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:
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:
Versement de fondsat least as often as it names itRetrait de fonds. A newwithdrawalmovement kind classifies the ones it does name, andsummarizePortfoliogainswithdrawalsandnetExternalFlow; but the split is by sign, so a negative deposit lands in the right bucket either way.Public API
ReconciliationReportgainsexact,roundingandunexplained, andoknow means "no unexplained gaps" rather than "no gaps".BalanceDiscrepancygainskind,previousLine,movementKind,previousBalance,statedMutation,appliedMutationandexactAmount.MovementKindgainswithdrawal.PortfolioSummarygainswithdrawalsandnetExternalFlow. New exports:withdrawalMatcher,externalFlows,DiscrepancyKind,WithdrawalMovement. README updated.Checks
pnpm lint && pnpm typecheck && pnpm testclean, plusdashboard:typecheck,dashboard:testanddashboard:build. 86 library tests, 72 dashboard tests. New coverage for rounding classification, the transition payload,exactAmounton a synthetic half-centime row, sign-based flow splitting and per-currency running totals.Verified in the running app against the real statement (
oknow 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