feat: parse DEGIRO exports in any interface language - #39
Merged
Conversation
DEGIRO localizes only the header row of Account.csv. Switching the interface language to English rewrites line 1 and leaves the body alone, so an English export from a French account still carries French descriptions and French decimals. Header language and number format are therefore independent axes, and the English dialect reads either format: when a value carries both separators the last one is the decimal mark, and a lone separator is a decimal mark unless the value is a run of exact three-digit groups. Descriptions are a third axis, so the built-in matchers now recognise both languages regardless of which dialect read the header. Two bugs surfaced while adding US-format coverage: a grouped-thousands quantity parsed as its first digit, and a product name starting with digits was partly eaten by the quantity capture. Cash transfers now match before deposits and withdrawals, so an internal transfer that names the cash account cannot be counted as external cash in. Verified against three real exports of the same account: the English and French files yield identical movements, field by field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… unknown DEGIRO's export has the same twelve columns in every language: the mutation and the balance each pair a currency cell with an amount cell under one header label, so the ninth and eleventh header cells are always empty. That shape identifies the file without reading a word of it, which is what genericDialect matches once the language-aware dialects have declined. It reads dashed, slashed and ISO dates, and numbers by the same per-value rules as the English dialect. Guessing is only acceptable if it admits to guessing, so a dialect can now declare itself `heuristic` and parsing turns that into a warning on the result naming the header line it was decided from. The dashboard's parse-health panel repeats it in words, since a lone unexplained warning next to "every row was understood" is exactly the failure this panel exists to prevent. Two narrower guesses come with it. A header that tokenizes to a single cell is retried with a semicolon and then a tab, unless the caller named a delimiter. And two negative-priority matchers recover trades from the shape of a description alone — quantity, product, price, currency, ISIN — taking the side from the sign of the mutation, so a Dutch or Portuguese trade classifies without anyone teaching the library those verbs. Descriptions with no such shape stay unknown with their amounts intact, so balances still reconcile. parseEnglishDecimal is now parseFlexibleDecimal, shared by both dialects that need it. The name was introduced earlier on this branch and has never been released. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CodeQL flags six js/polynomial-redos alerts across the description parsers, four of them pre-existing patterns this branch happens to touch. All six come from the same shape: a lazy or starred segment whose character class overlaps the separator that follows it, so a hostile description makes the engine try every split. `PRICE_TAIL` against 20k spaces takes 167ms, and the FX conversion prefix takes 569ms — both quadratic, both reachable from any CSV a caller hands the library. The trade, price-tail and cash-transfer parsers now find their split points with indexOf and slice, which is what the regexes were emulating anyway: the first `@` after the quantity, the last `(`, the last `:`, a three-letter currency at the end. What is left in a regex has disjoint classes, and the FX conversion prefix folds its two adjacent `\s*` runs into one class. Behaviour is unchanged: the three real exports still yield byte-identical movements, and the same 129 tests pass. A new suite feeds each witness CodeQL named at 50k repetitions and asserts the classifier still returns, which the old parsers would not have. 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.
What broke
An
Account.csvexported with the interface language set to English failed outright:DEGIRO localizes only the header row. Line 1 is rewritten, the body is left alone — an English export from a French account still says
Frais DEGIRO de courtageand still writes14 980,01. Two exports of the same account, one withHide cash movementson, came back byte-identical: that toggle changes the on-screen table, never the file.So header language, number format and description language are three independent axes, and this branch stops treating them as one — then closes the set with a fallback for the languages nobody has taught it.
Recognising the language
englishDialect— registered afterfrenchDialect, same positional layout,DD-MM-YYYYdates. It reads either number format, because an English export can carry either: when a value has both separators the last one is the decimal mark, and a lone separator is a decimal mark unless the value is a run of exact three-digit groups (1,060,200).Bilingual matchers — descriptions are matched independently of the dialect that read the header, so a French body behind an English header classifies exactly as before.
Two bugs found on the way, both in the trade-description quantity capture:
Buy 1,060 X@…parsed as quantity1, andAchat 42 3M Company@…ate the leading3of the product name.Matcher order — cash transfers now match before deposits and withdrawals, so an internal transfer that names the cash account cannot inflate external cash in.
Guessing the rest
DEGIRO's column layout is identical in every language: twelve columns, where the mutation and the balance each pair a currency cell with an amount cell under a single header label, leaving the 9th and 11th header cells empty.
genericDialectmatches that shape and nothing else — it reads no header text at all — and handles dashed, slashed and ISO dates. It is registered last, so a French or English file never reaches it.A guess is only acceptable if it admits to being one. A dialect can now declare itself
heuristic, and parsing turns that into a warning onParseResult.warningsnaming the header line:The dashboard's parse-health panel repeats it in plain words — a lone unexplained warning sitting next to "every row was understood" is precisely the failure that panel exists to prevent.
Two narrower guesses ride along:
;and then a tab. Passingdelimiterexplicitly turns this off.structuralTradeMatcherandstructuralFxTradeMatcherrecover trades from the shape of a description —<qty> <product>@<price> <CCY> (<ISIN>)— taking the side from the sign of the mutation, money out being a buy. Both run at negative priority, after every language-aware matcher has declined, soKoop 1.060 SMI ETF@106,02 CHF (CH0019852802)classifies as a buy without anyone teaching the library Dutch.Descriptions with no such shape — a dividend, a fee, a deposit in an unknown language — stay
unknownwith their amounts intact, so balances still reconcile and cash still adds up.Dashboard
The drop zone now carries a six-step export guide: web client,
Inbox→Account statement, widen the date range (a narrow range silently truncates positions and realized P/L), leaveCurr.onAll, theHide cash movementscaveat, then the download button → CSV.Linear-time parsing
CodeQL flagged six
js/polynomial-redosalerts across the description parsers — four of them pre-existing patterns this branch happens to touch. All six are the same shape: a lazy or starred segment whose character class overlaps the separator after it, so a hostile description makes the engine try every split.PRICE_TAILagainst 20k spaces took 167ms and the FX conversion prefix 569ms, both quadratic and both reachable from any CSV handed to the library.The trade, price-tail and cash-transfer parsers now find their split points with
indexOf/slice— which is what the regexes were emulating: the first@after the quantity, the last(, the last:, a three-letter currency at the end. What stays in a regex has disjoint classes.test/redos.test.tsfeeds each witness CodeQL named at 50k repetitions; the suite runs in 5ms.Verification
Three real exports of the same account — French, English, English with cash movements hidden. Each: 258 records, 0 errors, 0 warnings, 0 unknown movements, balances reconcile, 10 positions,
26752.76 CHF. The English and French results were compared movement by movement, field by field: identical, before and after the fallback landed.test/fixtures/Account-en.csvis the existing synthetic fixture behind an English header. Further suites cover English descriptions with US number formatting, a Dutch export through the fallback, and a semicolon-delimited one. 137 library tests, 74 dashboard tests.Caveats
🤖 Generated with Claude Code