Skip to content

feat: parse DEGIRO exports in any interface language - #39

Merged
nyg merged 3 commits into
masterfrom
nyg/degiro-csv-parsing-24bfa0
Aug 23, 2026
Merged

feat: parse DEGIRO exports in any interface language#39
nyg merged 3 commits into
masterfrom
nyg/degiro-csv-parsing-24bfa0

Conversation

@nyg

@nyg nyg commented Aug 22, 2026

Copy link
Copy Markdown
Owner

What broke

An Account.csv exported with the interface language set to English failed outright:

No registered dialect recognised the CSV header: [Date, Time, Value date, Product, ISIN, Description, FX, Change, , Balance, , Order Id]

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 courtage and still writes 14 980,01. Two exports of the same account, one with Hide cash movements on, 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 after frenchDialect, same positional layout, DD-MM-YYYY dates. 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 quantity 1, and Achat 42 3M Company@… ate the leading 3 of 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. genericDialect matches 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 on ParseResult.warnings naming the header line:

result.dialect.id;         // 'generic'
result.dialect.heuristic;  // true
result.warnings[0].message // 'No dialect recognised this header, so the "generic" fallback …'

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:

  • A header that tokenizes to a single cell is retried with ; and then a tab. Passing delimiter explicitly turns this off.
  • structuralTradeMatcher and structuralFxTradeMatcher recover 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, so Koop 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 unknown with 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, InboxAccount statement, widen the date range (a narrow range silently truncates positions and realized P/L), leave Curr. on All, the Hide cash movements caveat, then the download button → CSV.

Linear-time parsing

CodeQL flagged six js/polynomial-redos alerts 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_TAIL against 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.ts feeds 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.csv is 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

  • The English description strings are informed, not verified — the exports that prompted this have French descriptions throughout. The dialects and the number formats are verified against real files.
  • The fallback recovers rows, balances, cash and trades in an unknown language, but not the wording of dividends, fees or deposits. Those need a real export in that language to pin down.

🤖 Generated with Claude Code

nyg and others added 2 commits August 23, 2026 00:27
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>
@nyg nyg changed the title feat: parse exports in any DEGIRO interface language feat: parse DEGIRO exports in any interface language Aug 23, 2026
Comment thread src/classify/descriptions.ts Fixed
Comment thread src/classify/descriptions.ts Fixed
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>
@nyg
nyg merged commit f5a1fcc into master Aug 23, 2026
4 checks passed
@nyg
nyg deleted the nyg/degiro-csv-parsing-24bfa0 branch August 23, 2026 08:05
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.

2 participants