From ce00df4ce93434837335372e8acc56b15b9cd40f Mon Sep 17 00:00:00 2001 From: nyg Date: Sun, 23 Aug 2026 00:26:57 +0200 Subject: [PATCH 1/3] feat: parse English DEGIRO exports 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 --- .gitignore | 1 + AGENTS.md | 4 +- README.md | 42 +++- .../dashboard/src/components/dropzone.tsx | 43 +++- src/classify/descriptions.ts | 26 +- src/classify/matchers/cash.ts | 4 +- src/classify/matchers/dividend.ts | 6 +- src/classify/matchers/fees.ts | 5 +- src/classify/matchers/fx.ts | 3 +- src/classify/registry.ts | 4 +- src/dialects/common.ts | 47 ++++ src/dialects/en.ts | 46 ++++ src/dialects/fr.ts | 56 +---- src/dialects/index.ts | 2 + src/dialects/registry.ts | 3 +- test/classify.test.ts | 87 +++++++ test/dialect.test.ts | 68 ++++- test/fixtures/Account-en.csv | 237 ++++++++++++++++++ test/integration.test.ts | 158 ++++++++++-- 19 files changed, 733 insertions(+), 109 deletions(-) create mode 100644 src/dialects/common.ts create mode 100644 src/dialects/en.ts create mode 100644 test/fixtures/Account-en.csv diff --git a/.gitignore b/.gitignore index 118d227..587eca4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ coverage/ *.log *.csv !test/fixtures/Account.csv +!test/fixtures/Account-en.csv diff --git a/AGENTS.md b/AGENTS.md index 0b0c596..fd2c6f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Run `pnpm lint && pnpm typecheck && pnpm test` before proposing a change; that i ``` src/ csv/ tokenizer (papaparse) + Node row stream + shared options - dialects/ Dialect interface, French dialect, DialectRegistry + dialects/ Dialect interface, French and English dialects, DialectRegistry records/ RawRecord — a normalised, dialect-agnostic row classify/ Matcher registry + matchers (trade, fx, dividend, fees, interest, cash) group/ GroupingStrategy pipeline + Transaction union @@ -69,7 +69,7 @@ The pipeline is `tokenizeCsv` → `mapRow` (dialect) → `ClassifierRegistry.cla - TypeScript is strict with `noUncheckedIndexedAccess` and `verbatimModuleSyntax`. Type-only imports must use `import type` — `@typescript-eslint/consistent-type-imports` is an error, not a warning. - Prettier: single quotes, semicolons, trailing commas, 100 columns. `.csv` fixtures and `CHANGELOG.md` are excluded and must not be reformatted. - `examples/**/components/ui/**` is generated by the shadcn CLI and kept verbatim so it stays upgradable; two lint rules are disabled there. Do not hand-edit those files. -- `test/fixtures/Account.csv` is synthetic but byte-sensitive: French decimals, `U+202F` thousands separators, double-spaced product names, and running balances that reconcile exactly. Editing it usually means fixing the balances too. A real `Account.csv` at the repo root is git-ignored. +- `test/fixtures/Account.csv` is synthetic but byte-sensitive: French decimals, `U+202F` thousands separators, double-spaced product names, and running balances that reconcile exactly. Editing it usually means fixing the balances too. `test/fixtures/Account-en.csv` is the same body behind the English header; keep the two bodies identical, since a test asserts they classify the same. Both are unignored by name in `.gitignore`. A real `Account.csv` at the repo root is git-ignored. - New behaviour needs a test in `test/` (library) or `examples/dashboard/test/` (analytics). The analytics layer imports nothing from React precisely so it stays testable headlessly. ## Git and PRs diff --git a/README.md b/README.md index fa54c44..21a1e6b 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ A modern, ESM-only TypeScript library that turns a DEGIRO account statement into - 🧮 **Exact money** with [`big.js`](https://github.com/MikeMcl/big.js) — no float drift - 🧩 **Extensible** dialects, classifiers and grouping strategies +- 🌐 **French and English exports** out of the box, whatever number format they carry - 🧠 **Typed domain model** — discriminated unions for movements & transactions - 🪶 **Lenient parsing** — per-row problems are collected, never thrown - 🌊 **Streaming** parser for very large files @@ -108,6 +109,19 @@ const result = await parseDegiroStream(createReadStream('./Account.csv')); Parsing is **lenient**: a row with an unparseable date is dropped and reported as an `error`; a partially-parseable amount becomes a `warning`. The only thrown conditions are an empty input and a header that matches no dialect (`UnknownDialectError`). +## Supported exports + +DEGIRO localizes the **header row** of `Account.csv` to the interface language, and a built-in dialect recognises it: + +| Dialect | `id` | Header | +| ---------------- | ---- | --------------------------------------------------------------------------------- | +| `frenchDialect` | `fr` | `Date,Heure,Date de,Produit,Code ISIN,Description,FX,Mouvements,,Solde,,ID Ordre` | +| `englishDialect` | `en` | `Date,Time,Value date,Product,ISIN,Description,FX,Change,,Balance,,Order Id` | + +Both share the positional column layout and `DD-MM-YYYY` dates. They differ in how they read numbers: `frenchDialect` expects space thousands separators and a comma decimal mark, while `englishDialect` accepts either that or the US `1,060.20` form — DEGIRO does **not** switch the body of the file to English number formatting when you switch the interface language, so an English export can carry either. When a value contains both `,` and `.`, the last one is the decimal mark; a lone separator is a decimal mark unless the value is a run of exact 3-digit groups (`1,060,200`). + +Descriptions are a separate axis: switching the interface language rewrites the header but leaves the description text alone, so an English-header export from a French account still says `Frais DEGIRO de courtage`. The built-in matchers therefore recognise both languages regardless of which dialect read the header. Anything they do not recognise becomes an `unknown` movement — never a dropped row. + ## Domain model ### Movements @@ -237,27 +251,31 @@ Every stage is pluggable. You rarely need to fork the library to support a new e ### Custom dialect (new locale / layout) +French and English are built in; here is a third locale — Dutch headers, dot thousands, comma decimals: + ```ts -import { parseDegiroCsv, frenchDialect, parseFrenchDateTime, type Dialect } from 'libdegiro'; +import { parseDegiroCsv, DEGIRO_COLUMNS, parseDegiroDateTime, type Dialect } from 'libdegiro'; -const englishDialect: Dialect = { - id: 'en', - label: 'DEGIRO English', - columns: frenchDialect.columns, // same positional layout - matches: (header) => header.includes('Change') && header.includes('Balance'), +const dutchDialect: Dialect = { + id: 'nl', + label: 'DEGIRO Dutch', + columns: DEGIRO_COLUMNS, // the shared positional layout + matches: (header) => header.includes('Omschrijving') && header.includes('Mutatie'), parseDecimal: (raw) => { - const n = raw.trim().replace(/,/g, ''); // US thousands + const n = raw.trim().replace(/\./g, '').replace(',', '.'); // 1.060,20 return /^-?\d+(\.\d+)?$/.test(n) ? n : null; }, - parseDateTime: parseFrenchDateTime, - parseDate: (d) => parseFrenchDateTime(d), + parseDateTime: parseDegiroDateTime, + parseDate: (d) => parseDegiroDateTime(d), }; -parseDegiroCsv(csv, { dialects: [englishDialect] }); +parseDegiroCsv(csv, { dialects: [dutchDialect] }); // or force it, skipping detection: -parseDegiroCsv(csv, { dialect: englishDialect }); +parseDegiroCsv(csv, { dialect: dutchDialect }); ``` +A dialect registered through `dialects` replaces the built-ins; pass `createDefaultDialectRegistry().register(dutchDialect)` to keep them. + ### Custom classifier (new movement description) ```ts @@ -318,4 +336,4 @@ pnpm build # tsdown -> dist/ (ESM + .d.ts + sourcemaps) ### Test fixture -`test/fixtures/Account.csv` is a **synthetic** statement, not a real export. It mirrors the shape of a genuine French DEGIRO file — column layout, movement types, order-id grouping, French decimals with `U+202F` thousands separators, double-spaced product names — and its running balances reconcile exactly, but every figure, date, ISIN and order id is fabricated. Drop your own `Account.csv` at the repo root to try the library against real data; it is git-ignored. +`test/fixtures/Account.csv` is a **synthetic** statement, not a real export. It mirrors the shape of a genuine French DEGIRO file — column layout, movement types, order-id grouping, French decimals with `U+202F` thousands separators, double-spaced product names — and its running balances reconcile exactly, but every figure, date, ISIN and order id is fabricated. `test/fixtures/Account-en.csv` is the same statement behind an English header, which is exactly what DEGIRO produces when you switch the interface language. Drop your own `Account.csv` at the repo root to try the library against real data; it is git-ignored. diff --git a/examples/dashboard/src/components/dropzone.tsx b/examples/dashboard/src/components/dropzone.tsx index 22c72b6..1deafbd 100644 --- a/examples/dashboard/src/components/dropzone.tsx +++ b/examples/dashboard/src/components/dropzone.tsx @@ -1,10 +1,14 @@ -import { useCallback, useRef, useState, type DragEvent } from 'react'; -import { FileUp, ShieldCheck } from 'lucide-react'; +import { useCallback, useRef, useState, type DragEvent, type ReactNode } from 'react'; +import { Download, FileUp, ShieldCheck } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { SAMPLE_FILE_NAME, sampleCsv } from '@/lib/sample'; import { useStatement } from '@/state/statement-context'; +function Ui({ children }: { children: ReactNode }) { + return {children}; +} + export function Dropzone() { const { load, state } = useStatement(); const [dragging, setDragging] = useState(false); @@ -53,7 +57,7 @@ export function Dropzone() {

Drop your DEGIRO Account.csv here

- Export it from DEGIRO under Inbox → Account statement. + English and French statements are both recognised.

@@ -83,6 +87,39 @@ export function Dropzone() { ) : null} +
+

+ + Exporting Account.csv from DEGIRO +

+
    +
  1. + Sign in to degiro.com in a browser. The statement export lives in the web + client. +
  2. +
  3. + Open Inbox, then the Account statement tab. +
  4. +
  5. + Set Start date to the day you opened the account, or anything earlier, and{' '} + End date to today. Every number here is computed from the rows in the file, so + a narrower range silently gives you partial positions, fees and realized P/L. +
  6. +
  7. + Leave Curr. on All and the product search empty, so no currency or + instrument is filtered out. +
  8. +
  9. + Hide cash movements only changes the table on screen. The export contains every + row either way, and this dashboard needs those rows to reconcile your balances. +
  10. +
  11. + Click the download button at the top right of the table and choose CSV. Drop + the file it saves — Account.csv — above. +
  12. +
+
+

Your statement is parsed in this tab and never uploaded. The page blocks all network access, diff --git a/src/classify/descriptions.ts b/src/classify/descriptions.ts index f6bb298..36c1d97 100644 --- a/src/classify/descriptions.ts +++ b/src/classify/descriptions.ts @@ -1,19 +1,23 @@ import type { Dialect } from '../dialects/types'; import { Money } from '../money/money'; -/** Leading quantity (digits with space thousands separators) then the remainder. */ -const LEADING_QTY = /^([0-9][0-9\u00a0\u202f ]*)(.*)$/; +/** Leading quantity (digits with grouped thousands) then the remainder. */ +const LEADING_QTY = /^([0-9][0-9\u00a0\u202f]*(?:[ .,\u00a0\u202f][0-9]{3})*)(?:\s+(.*))?$/; +const QTY_GROUPING = /[ .,\u00a0\u202f]/g; /** ` ()` tail of a trade description. */ const PRICE_TAIL = /^(.+?)\s+([A-Za-z]{3})\s+\(([^)]*)\)\s*$/; -/** `Achat|Vente @` */ -const TRADE = /^(Achat|Vente)\s+(.+?)@(.+)$/; +/** `Achat|Vente|Buy|Sell @` */ +const TRADE = /^(Achat|Vente|Buy|Sell)\s+(.+?)@(.+)$/i; +const BUY_SIDE = /^(achat|buy)$/i; /** Settlement prefix on FX trade rows. */ -const FX_SETTLEMENT_PREFIX = /^R[èe]glement transaction devise:\s*/i; +const FX_SETTLEMENT_PREFIX = + /^(?:R[èe]glement transaction devise|(?:Currency|FX)\s+(?:transaction\s+)?settlement)\s*:\s*/i; /** Currency pair such as `EUR/CHF`. */ const FX_PAIR = /^[A-Za-z]{3}\/[A-Za-z]{3}$/; -/** `Virement vers|depuis ... : ` */ +/** `Virement|Transfer|Deposit|Withdrawal vers|depuis|to|from ... : ` */ const CASH_TRANSFER = - /^Virement\s+(vers|depuis)\b.*:\s*([0-9][0-9\u00a0\u202f .,]*?)\s+([A-Za-z]{3})\s*$/i; + /^(?:Virement|Transfer|Deposit|Withdrawal)\s+(vers|depuis|to|from)\b.*:\s*([0-9][0-9\u00a0\u202f .,]*?)\s+([A-Za-z]{3})\s*$/i; +const TO_CASH_ACCOUNT = /^(vers|to)$/i; /** Parse a localized integer quantity (with space thousands separators). */ export function parseQuantity(raw: string, dialect: Dialect): number | null { @@ -41,12 +45,14 @@ export function parseTradeDescription(description: string, dialect: Dialect): Pa const trade = TRADE.exec(description.trim()); if (!trade) return null; - const side = trade[1] === 'Achat' ? 'buy' : 'sell'; + const side = BUY_SIDE.test(trade[1] ?? '') ? 'buy' : 'sell'; const qtyAndProduct = trade[2] ?? ''; const priceTail = trade[3] ?? ''; const qtyMatch = LEADING_QTY.exec(qtyAndProduct); - const quantity = qtyMatch ? parseQuantity(qtyMatch[1] ?? '', dialect) : null; + const quantity = qtyMatch + ? parseQuantity((qtyMatch[1] ?? '').replace(QTY_GROUPING, ''), dialect) + : null; const product = qtyMatch ? (qtyMatch[2] ?? '').trim() || null : qtyAndProduct.trim() || null; const priceMatch = PRICE_TAIL.exec(priceTail); @@ -119,7 +125,7 @@ export function parseCashTransferDescription( const match = CASH_TRANSFER.exec(description.trim()); if (!match) return null; - const direction = (match[1] ?? '').toLowerCase() === 'vers' ? 'toCashAccount' : 'fromCashAccount'; + const direction = TO_CASH_ACCOUNT.test(match[1] ?? '') ? 'toCashAccount' : 'fromCashAccount'; const decimal = dialect.parseDecimal(match[2] ?? ''); const currency = match[3] ?? ''; const amount = decimal !== null && currency !== '' ? new Money(decimal, currency) : null; diff --git a/src/classify/matchers/cash.ts b/src/classify/matchers/cash.ts index 209f700..a4a2b51 100644 --- a/src/classify/matchers/cash.ts +++ b/src/classify/matchers/cash.ts @@ -2,8 +2,8 @@ import type { Matcher } from '../types'; import { parseCashTransferDescription } from '../descriptions'; const CASH_SWEEP = /^degiro cash sweep transfer/i; -const DEPOSIT = /^versement de fonds/i; -const WITHDRAWAL = /^retrait de fonds/i; +const DEPOSIT = /^(?:versement de fonds|(?:processed\s+)?(?:flatex\s+)?deposit)\b/i; +const WITHDRAWAL = /^(?:retrait de fonds|(?:processed\s+)?(?:flatex\s+)?withdrawal)\b/i; /** Matches a cash sweep between the DEGIRO account and the cash account. */ export const cashSweepMatcher: Matcher = { diff --git a/src/classify/matchers/dividend.ts b/src/classify/matchers/dividend.ts index a476389..692d42c 100644 --- a/src/classify/matchers/dividend.ts +++ b/src/classify/matchers/dividend.ts @@ -1,8 +1,8 @@ import type { Matcher } from '../types'; -const DIVIDEND_TAX = /^imp[oô]ts? sur (le )?dividende/i; -const DIVIDEND = /^dividende\b/i; -const CAPITAL_RETURN = /^remboursement de capital/i; +const DIVIDEND_TAX = /^(?:imp[oô]ts? sur (?:le )?dividende|dividend tax|withholding tax)/i; +const DIVIDEND = /^dividende?\b/i; +const CAPITAL_RETURN = /^(?:remboursement de capital|capital return|return of capital)/i; /** Matches dividend tax withholding (`Impôts sur dividende`). */ export const dividendTaxMatcher: Matcher = { diff --git a/src/classify/matchers/fees.ts b/src/classify/matchers/fees.ts index fd9c596..a6327b8 100644 --- a/src/classify/matchers/fees.ts +++ b/src/classify/matchers/fees.ts @@ -1,8 +1,9 @@ import type { Matcher } from '../types'; import { extractYear } from '../descriptions'; -const BROKERAGE = /^frais degiro de courtage/i; -const CONNECTIVITY = /^frais de connexion aux places boursi[èe]res/i; +const BROKERAGE = /^(?:frais degiro de courtage|degiro transaction)/i; +const CONNECTIVITY = + /^(?:frais de connexion aux places boursi[èe]res|degiro exchange connect(?:ion|ivity) fee)/i; /** Matches a brokerage / third-party transaction fee. */ export const brokerageFeeMatcher: Matcher = { diff --git a/src/classify/matchers/fx.ts b/src/classify/matchers/fx.ts index 9bfb4fb..753d5cb 100644 --- a/src/classify/matchers/fx.ts +++ b/src/classify/matchers/fx.ts @@ -2,7 +2,8 @@ import type { Matcher } from '../types'; import { parseFxTradeDescription } from '../descriptions'; /** `Opération de change - Crédit/Débit` (accent spelling varies between legs). */ -const FX_CONVERSION = /^op[ée]ration de change\s*-\s*(cr[ée]dit|d[ée]bit)/i; +const FX_CONVERSION = + /^(?:op[ée]ration de change|currency exchange|fx)\s*[-(]?\s*(cr[ée]dit|d[ée]bit)/i; /** Matches a currency-pair trade (`Achat/Vente EUR/CHF@ CCY`). */ export const fxTradeMatcher: Matcher = { diff --git a/src/classify/registry.ts b/src/classify/registry.ts index 5a9e038..1076b9d 100644 --- a/src/classify/registry.ts +++ b/src/classify/registry.ts @@ -80,10 +80,10 @@ export const defaultMatchers: readonly Matcher[] = [ brokerageFeeMatcher, connectivityFeeMatcher, interestMatcher, - depositMatcher, - withdrawalMatcher, cashSweepMatcher, cashTransferMatcher, + depositMatcher, + withdrawalMatcher, ]; /** Create a registry pre-populated with all built-in matchers. */ diff --git a/src/dialects/common.ts b/src/dialects/common.ts new file mode 100644 index 0000000..a44ac52 --- /dev/null +++ b/src/dialects/common.ts @@ -0,0 +1,47 @@ +import type { CsvRow } from '../csv/tokenizer'; +import type { ColumnMap } from './types'; + +export const DEGIRO_COLUMNS: ColumnMap = { + date: 0, + time: 1, + valueDate: 2, + product: 3, + isin: 4, + description: 5, + fx: 6, + mutationCurrency: 7, + mutationAmount: 8, + balanceCurrency: 9, + balanceAmount: 10, + orderId: 11, +}; + +export const SPACE_SEPARATORS = /[\s\u00a0\u202f]/g; + +const DMY = /^(\d{2})-(\d{2})-(\d{4})$/; +const HM = /^(\d{1,2}):(\d{2})$/; + +export function hasHeaderTokens(header: CsvRow, tokens: readonly string[]): boolean { + const cells = header.map((cell) => cell.trim()); + return tokens.every((token) => cells.includes(token)); +} + +export function parseDegiroDateTime(date: string, time = '00:00'): Date | null { + const dateMatch = DMY.exec(date.trim()); + if (!dateMatch) return null; + const timeMatch = HM.exec(time.trim()); + if (!timeMatch) return null; + + const [, dd, mm, yyyy] = dateMatch; + const [, hh, min] = timeMatch; + const ms = Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd), Number(hh), Number(min)); + const result = new Date(ms); + if ( + result.getUTCDate() !== Number(dd) || + result.getUTCMonth() !== Number(mm) - 1 || + result.getUTCFullYear() !== Number(yyyy) + ) { + return null; + } + return result; +} diff --git a/src/dialects/en.ts b/src/dialects/en.ts new file mode 100644 index 0000000..a287618 --- /dev/null +++ b/src/dialects/en.ts @@ -0,0 +1,46 @@ +import type { CsvRow } from '../csv/tokenizer'; +import type { Dialect } from './types'; +import { DEGIRO_COLUMNS, SPACE_SEPARATORS, hasHeaderTokens, parseDegiroDateTime } from './common'; + +const ENGLISH_HEADER_TOKENS = [ + 'Date', + 'Time', + 'Product', + 'ISIN', + 'Description', + 'Change', + 'Balance', +] as const; + +export function parseEnglishDecimal(raw: string): string | null { + const trimmed = raw.trim().replace(SPACE_SEPARATORS, ''); + if (trimmed === '') return null; + + const lastComma = trimmed.lastIndexOf(','); + const lastDot = trimmed.lastIndexOf('.'); + let normalized: string; + if (lastComma >= 0 && lastDot >= 0) { + const commaIsDecimal = lastComma > lastDot; + const grouping = commaIsDecimal ? '.' : ','; + normalized = trimmed.split(grouping).join('').replace(',', '.'); + } else { + const parts = trimmed.split(lastComma >= 0 ? ',' : '.'); + const grouped = parts.length > 2 && parts.slice(1).every((part) => /^\d{3}$/.test(part)); + normalized = grouped ? parts.join('') : parts.join('.'); + } + return /^-?\d+(\.\d+)?$/.test(normalized) ? normalized : null; +} + +export const englishDialect: Dialect = { + id: 'en', + label: 'DEGIRO English (Account.csv)', + columns: DEGIRO_COLUMNS, + matches(header: CsvRow): boolean { + return hasHeaderTokens(header, ENGLISH_HEADER_TOKENS); + }, + parseDecimal: parseEnglishDecimal, + parseDateTime: parseDegiroDateTime, + parseDate(date: string): Date | null { + return parseDegiroDateTime(date); + }, +}; diff --git a/src/dialects/fr.ts b/src/dialects/fr.ts index 63ba1a5..3d4212d 100644 --- a/src/dialects/fr.ts +++ b/src/dialects/fr.ts @@ -1,21 +1,6 @@ import type { CsvRow } from '../csv/tokenizer'; -import type { ColumnMap, Dialect } from './types'; - -/** Positional column layout of the French DEGIRO `Account.csv` export. */ -const FRENCH_COLUMNS: ColumnMap = { - date: 0, - time: 1, - valueDate: 2, - product: 3, - isin: 4, - description: 5, - fx: 6, - mutationCurrency: 7, - mutationAmount: 8, - balanceCurrency: 9, - balanceAmount: 10, - orderId: 11, -}; +import type { Dialect } from './types'; +import { DEGIRO_COLUMNS, SPACE_SEPARATORS, hasHeaderTokens, parseDegiroDateTime } from './common'; /** Header tokens that uniquely identify a French export. */ const FRENCH_HEADER_TOKENS = [ @@ -28,11 +13,6 @@ const FRENCH_HEADER_TOKENS = [ 'Solde', ] as const; -const DMY = /^(\d{2})-(\d{2})-(\d{4})$/; -const HM = /^(\d{1,2}):(\d{2})$/; -/** Spaces used as thousands separators, including NBSP / narrow NBSP. */ -const THOUSANDS_SEPARATORS = /[\s\u00a0\u202f]/g; - /** * Parse a French/European decimal string into a plain decimal string. * @@ -42,31 +22,12 @@ const THOUSANDS_SEPARATORS = /[\s\u00a0\u202f]/g; export function parseFrenchDecimal(raw: string): string | null { const trimmed = raw.trim(); if (trimmed === '') return null; - const normalized = trimmed.replace(THOUSANDS_SEPARATORS, '').replace(/,/g, '.'); + const normalized = trimmed.replace(SPACE_SEPARATORS, '').replace(/,/g, '.'); return /^-?\d+(\.\d+)?$/.test(normalized) ? normalized : null; } /** Parse a `DD-MM-YYYY` date (optionally with `HH:MM` time) into a UTC `Date`. */ -export function parseFrenchDateTime(date: string, time = '00:00'): Date | null { - const dateMatch = DMY.exec(date.trim()); - if (!dateMatch) return null; - const timeMatch = HM.exec(time.trim()); - if (!timeMatch) return null; - - const [, dd, mm, yyyy] = dateMatch; - const [, hh, min] = timeMatch; - const ms = Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd), Number(hh), Number(min)); - const result = new Date(ms); - // Guard against overflow (e.g. 32-13-2026 silently rolling over). - if ( - result.getUTCDate() !== Number(dd) || - result.getUTCMonth() !== Number(mm) - 1 || - result.getUTCFullYear() !== Number(yyyy) - ) { - return null; - } - return result; -} +export const parseFrenchDateTime = parseDegiroDateTime; /** * Built-in dialect for the French DEGIRO `Account.csv` export. @@ -77,14 +38,13 @@ export function parseFrenchDateTime(date: string, time = '00:00'): Date | null { export const frenchDialect: Dialect = { id: 'fr', label: 'DEGIRO French (Account.csv)', - columns: FRENCH_COLUMNS, + columns: DEGIRO_COLUMNS, matches(header: CsvRow): boolean { - const cells = header.map((c) => c.trim()); - return FRENCH_HEADER_TOKENS.every((token) => cells.includes(token)); + return hasHeaderTokens(header, FRENCH_HEADER_TOKENS); }, parseDecimal: parseFrenchDecimal, - parseDateTime: parseFrenchDateTime, + parseDateTime: parseDegiroDateTime, parseDate(date: string): Date | null { - return parseFrenchDateTime(date); + return parseDegiroDateTime(date); }, }; diff --git a/src/dialects/index.ts b/src/dialects/index.ts index 277e659..dbdc233 100644 --- a/src/dialects/index.ts +++ b/src/dialects/index.ts @@ -1,3 +1,5 @@ export * from './types'; +export * from './common'; export * from './fr'; +export * from './en'; export * from './registry'; diff --git a/src/dialects/registry.ts b/src/dialects/registry.ts index 1df392c..8de6454 100644 --- a/src/dialects/registry.ts +++ b/src/dialects/registry.ts @@ -2,6 +2,7 @@ import type { CsvRow } from '../csv/tokenizer'; import { UnknownDialectError } from '../errors'; import type { Dialect } from './types'; import { frenchDialect } from './fr'; +import { englishDialect } from './en'; /** Options for registering a dialect. */ export interface RegisterDialectOptions { @@ -62,7 +63,7 @@ export class DialectRegistry { /** Registry pre-populated with all built-in dialects. */ export function createDefaultDialectRegistry(): DialectRegistry { - return new DialectRegistry([frenchDialect]); + return new DialectRegistry([frenchDialect, englishDialect]); } /** Shared registry containing the built-in dialects. */ diff --git a/test/classify.test.ts b/test/classify.test.ts index 0f41987..f438d1c 100644 --- a/test/classify.test.ts +++ b/test/classify.test.ts @@ -5,6 +5,7 @@ import { tokenizeCsv, mapRow, frenchDialect, + englishDialect, defaultClassifier, createDefaultClassifierRegistry, type Matcher, @@ -158,6 +159,92 @@ describe('individual matchers', () => { }); }); +describe('English descriptions', () => { + const classify = (rec: RawRecord) => defaultClassifier.classify(rec, englishDialect); + + it('parses a security buy and sell', () => { + const buy = classify( + record('Buy 1,060 SMI ETF@106.02 CHF (CH0019852802)', { isin: 'CH0019852802' }), + ); + expect(buy.kind).toBe('buy'); + if (buy.kind === 'buy') { + expect(buy.quantity).toBe(1060); + expect(buy.unitPrice?.toString()).toBe('106.02 CHF'); + } + expect(classify(record('Sell 10 VWCE@120.36 EUR (IE00BK5BQT80)')).kind).toBe('sell'); + }); + + it('parses FX pair trades and their settlement leg', () => { + const fx = classify(record('Buy 4,800 EUR/CHF@0.9412 CHF ()')); + expect(fx.kind).toBe('fxTrade'); + if (fx.kind === 'fxTrade') { + expect(fx.pair).toBe('EUR/CHF'); + expect(fx.quantity).toBe(4800); + expect(fx.settlement).toBe(false); + } + const settled = classify( + record('Currency transaction settlement: Sell 4,800 EUR/CHF@0.9412 CHF ()'), + ); + if (settled.kind === 'fxTrade') expect(settled.settlement).toBe(true); + }); + + it('distinguishes FX credit and debit legs in both spellings', () => { + expect(classify(record('Currency Exchange - Credit')).kind).toBe('fxCredit'); + expect(classify(record('Currency Exchange (Debit)')).kind).toBe('fxDebit'); + expect(classify(record('FX Credit')).kind).toBe('fxCredit'); + expect(classify(record('FX Debit')).kind).toBe('fxDebit'); + }); + + it('classifies dividend, dividend tax and capital return', () => { + expect(classify(record('Dividend')).kind).toBe('dividend'); + expect(classify(record('Dividend Tax')).kind).toBe('dividendTax'); + expect(classify(record('Return of Capital')).kind).toBe('capitalReturn'); + expect(classify(record('Capital Return')).kind).toBe('capitalReturn'); + }); + + it('classifies fees and extracts the connectivity year', () => { + expect(classify(record('DEGIRO Transaction and/or Third Party Fees')).kind).toBe( + 'brokerageFee', + ); + const conn = classify(record('DEGIRO Exchange Connection Fee 2024 (Euronext Amsterdam - EAM)')); + expect(conn.kind).toBe('connectivityFee'); + if (conn.kind === 'connectivityFee') expect(conn.year).toBe(2024); + expect(classify(record('DEGIRO Exchange Connectivity Fee 2024')).kind).toBe('connectivityFee'); + }); + + it('classifies cash transfers with direction and stated amount', () => { + const out = classify(record('Transfer to your Cash Account at flatex Bank: 6,770.10 CHF')); + expect(out.kind).toBe('cashTransfer'); + if (out.kind === 'cashTransfer') { + expect(out.direction).toBe('toCashAccount'); + expect(out.statedAmount?.toString()).toBe('6770.1 CHF'); + } + const incoming = classify(record('Transfer from your Cash Account at flatex Bank: 213.25 EUR')); + if (incoming.kind === 'cashTransfer') expect(incoming.direction).toBe('fromCashAccount'); + }); + + it('prefers a cash transfer over a deposit when the row names the cash account', () => { + expect(classify(record('Deposit to your Cash Account at flatex Bank: 213.25 EUR')).kind).toBe( + 'cashTransfer', + ); + expect( + classify(record('Withdrawal from your Cash Account at flatex Bank: 213.25 EUR')).kind, + ).toBe('cashTransfer'); + }); + + it('classifies deposits and withdrawals, however they are qualified', () => { + expect(classify(record('Deposit')).kind).toBe('deposit'); + expect(classify(record('flatex Deposit')).kind).toBe('deposit'); + expect(classify(record('Withdrawal')).kind).toBe('withdrawal'); + expect(classify(record('Processed Flatex Withdrawal')).kind).toBe('withdrawal'); + }); + + it('does not mistake a French export read through the English dialect', () => { + expect(classify(record('Versement de fonds')).kind).toBe('deposit'); + expect(classify(record('Degiro Cash Sweep Transfer')).kind).toBe('cashSweep'); + }); +}); + describe('classifier extensibility', () => { it('classifies an otherwise-unknown description via a custom matcher', () => { // A referral bonus is not understood by the built-ins... diff --git a/test/dialect.test.ts b/test/dialect.test.ts index f8b5de1..73255d1 100644 --- a/test/dialect.test.ts +++ b/test/dialect.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from 'vitest'; import { frenchDialect, + englishDialect, parseFrenchDecimal, + parseEnglishDecimal, parseFrenchDateTime, DialectRegistry, createDefaultDialectRegistry, @@ -44,6 +46,45 @@ describe('parseFrenchDecimal', () => { }); }); +const englishHeader = [ + 'Date', + 'Time', + 'Value date', + 'Product', + 'ISIN', + 'Description', + 'FX', + 'Change', + '', + 'Balance', + '', + 'Order Id', +]; + +describe('parseEnglishDecimal', () => { + it('reads US thousands separators with a dot decimal mark', () => { + expect(parseEnglishDecimal('-1,060.20')).toBe('-1060.20'); + expect(parseEnglishDecimal('9,000.00')).toBe('9000.00'); + expect(parseEnglishDecimal('0.9412')).toBe('0.9412'); + }); + + it('reads the European format DEGIRO keeps in English exports', () => { + expect(parseEnglishDecimal('14\u202f980,01')).toBe('14980.01'); + expect(parseEnglishDecimal('-2145,60')).toBe('-2145.60'); + expect(parseEnglishDecimal('1.060,20')).toBe('1060.20'); + }); + + it('treats a repeated separator as grouping', () => { + expect(parseEnglishDecimal('1,060,200')).toBe('1060200'); + }); + + it('returns null for empty or invalid input', () => { + expect(parseEnglishDecimal('')).toBeNull(); + expect(parseEnglishDecimal('n/a')).toBeNull(); + expect(parseEnglishDecimal('1.060.20')).toBeNull(); + }); +}); + describe('parseFrenchDateTime', () => { it('parses DD-MM-YYYY with HH:MM as UTC', () => { const d = parseFrenchDateTime('01-02-2025', '12:21'); @@ -70,12 +111,37 @@ describe('frenchDialect', () => { }); }); +describe('englishDialect', () => { + it('matches the English header', () => { + expect(englishDialect.matches(englishHeader)).toBe(true); + }); + + it('does not match a French header', () => { + expect(englishDialect.matches(frenchHeader)).toBe(false); + }); + + it('shares the positional layout with the French dialect', () => { + expect(englishDialect.columns).toEqual(frenchDialect.columns); + }); + + it('reads DD-MM-YYYY dates as UTC', () => { + expect(englishDialect.parseDateTime('01-02-2025', '12:21')?.toISOString()).toBe( + '2025-02-01T12:21:00.000Z', + ); + }); +}); + describe('DialectRegistry', () => { it('detects the French dialect from the default registry', () => { const registry = createDefaultDialectRegistry(); expect(registry.detect(frenchHeader).id).toBe('fr'); }); + it('detects the English dialect from the default registry', () => { + const registry = createDefaultDialectRegistry(); + expect(registry.detect(englishHeader).id).toBe('en'); + }); + it('throws UnknownDialectError when nothing matches', () => { const registry = new DialectRegistry(); expect(() => registry.detect(frenchHeader)).toThrow(UnknownDialectError); @@ -90,6 +156,6 @@ describe('DialectRegistry', () => { }; const registry = createDefaultDialectRegistry().register(custom, { prepend: true }); expect(registry.detect(frenchHeader).id).toBe('custom'); - expect(registry.all().map((d) => d.id)).toEqual(['custom', 'fr']); + expect(registry.all().map((d) => d.id)).toEqual(['custom', 'fr', 'en']); }); }); diff --git a/test/fixtures/Account-en.csv b/test/fixtures/Account-en.csv new file mode 100644 index 0000000..a3865f7 --- /dev/null +++ b/test/fixtures/Account-en.csv @@ -0,0 +1,237 @@ +Date,Time,Value date,Product,ISIN,Description,FX,Change,,Balance,,Order Id +01-02-2025,12:21,01-02-2025,,,Degiro Cash Sweep Transfer,,EUR,"213,25",EUR,"12680,99", +01-02-2025,12:21,01-02-2025,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 213,25 EUR",,,,EUR,"12467,74", +01-02-2025,12:20,01-02-2025,,,Degiro Cash Sweep Transfer,,CHF,"3601,90",CHF,"32581,78", +01-02-2025,12:20,01-02-2025,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 3 601,9 CHF",,,,CHF,"28979,88", +01-02-2025,12:10,01-02-2025,,,Degiro Cash Sweep Transfer,,CHF,"-6770,10",CHF,"32581,78", +01-02-2025,12:10,01-02-2025,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 6 770,1 CHF",,,,CHF,"39351,88", +01-02-2025,11:42,01-02-2025,ISHARES CORE MSCI WORLD UCITS ETF,IE00B4L5Y983,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,12",EUR,"12680,99",6d3f0c1b-d6e3-4626-a943-88e4d5fbe1f5 +01-02-2025,11:42,01-02-2025,ISHARES CORE MSCI WORLD UCITS ETF,IE00B4L5Y983,"Achat 42 iShares Core MSCI World UCITS ETF USD (Acc)@96,11 CHF (IE00B4L5Y983)",,CHF,"-4036,62",CHF,"32581,78",6d3f0c1b-d6e3-4626-a943-88e4d5fbe1f5 +01-02-2025,11:35,01-02-2025,AMUNDI STOXX EUROPE 600 ETF ACC,LU0908500753,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,99",EUR,"12682,11",b1a5677b-ce87-440d-a586-145e3aead0fb +01-02-2025,11:35,01-02-2025,AMUNDI STOXX EUROPE 600 ETF ACC,LU0908500753,"Achat 250 Amundi STOXX Europe 600 UCITS ETF acc@4,2796 CHF (LU0908500753)",,CHF,"-1069,90",CHF,"36618,40",b1a5677b-ce87-440d-a586-145e3aead0fb +01-02-2025,11:33,01-02-2025,INVESCO MSCI JAPAN UCITS ETF HCHF ACC,IE00B3DWVS88,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-0,95",EUR,"12684,10",56002d4c-82c8-4005-a9c1-7686b0eff5a7 +01-02-2025,11:33,01-02-2025,INVESCO MSCI JAPAN UCITS ETF HCHF ACC,IE00B3DWVS88,"Achat 37 Invesco MSCI Japan UCITS ETF hCHF acc@33,48 CHF (IE00B3DWVS88)",,CHF,"-1238,76",CHF,"37688,30",56002d4c-82c8-4005-a9c1-7686b0eff5a7 +01-02-2025,11:33,01-02-2025,INVESCO MSCI JAPAN UCITS ETF HCHF ACC,IE00B3DWVS88,"Achat 7 Invesco MSCI Japan UCITS ETF hCHF acc@43,12 CHF (IE00B3DWVS88)",,CHF,"-301,84",CHF,"38927,06",56002d4c-82c8-4005-a9c1-7686b0eff5a7 +01-02-2025,11:33,01-02-2025,SPDR MSCI USA HCHF UCITS ETF CHF,IE00BYTRRD19,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,54",EUR,"12685,05",1d1f879a-e68c-4c93-a227-1e363143d3a0 +01-02-2025,11:33,01-02-2025,SPDR MSCI USA HCHF UCITS ETF CHF,IE00BYTRRD19,"Achat 69 SPDR MSCI USA hCHF UCITS ETF CHF acc@47,04 CHF (IE00BYTRRD19)",,CHF,"-3245,76",CHF,"39228,90",1d1f879a-e68c-4c93-a227-1e363143d3a0 +01-02-2025,11:32,01-02-2025,AMUNDI MSCI EMERGING MARKETS,LU1681045370,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,33",EUR,"12686,59",885e4878-c673-4401-abba-7093cadc4712 +01-02-2025,11:32,01-02-2025,AMUNDI MSCI EMERGING MARKETS,LU1681045370,"Achat 13 Amundi MSCI Emerging Markets UCITS 1C ETF@72,56 CHF (LU1681045370)",,CHF,"-943,28",CHF,"42474,66",885e4878-c673-4401-abba-7093cadc4712 +01-02-2025,11:31,01-02-2025,INVESCO NASDAQ-100 UCITS 1C ETF,IE00BYVTMS52,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,34",EUR,"12688,92",2d6225a2-7dd7-4267-a798-3b1155862792 +01-02-2025,11:31,01-02-2025,INVESCO NASDAQ-100 UCITS 1C ETF,IE00BYVTMS52,"Achat 40 Invesco Nasdaq-100 UCITS 1C ETF@50,4 CHF (IE00BYVTMS52)",,CHF,"-2016,00",CHF,"43417,94",2d6225a2-7dd7-4267-a798-3b1155862792 +01-02-2025,11:31,01-02-2025,,,Degiro Cash Sweep Transfer,,EUR,"5254,64",EUR,"12691,26", +01-02-2025,11:31,01-02-2025,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 5 254,64 EUR",,,,EUR,"7436,62", +01-02-2025,10:02,01-02-2025,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-3,01",EUR,"12691,26",923bd622-7a7c-4b58-ac38-512b0fdd3079 +01-02-2025,10:02,01-02-2025,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Vente 17 SPDR MSCI ACWI UCITS ETF USD Dis@128,4 CHF (IE00B44Z5B48)",,CHF,"2182,80",CHF,"45433,94",923bd622-7a7c-4b58-ac38-512b0fdd3079 +01-02-2025,10:02,01-02-2025,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,81",EUR,"12694,27",76e9d035-d4a9-433e-a616-d64c9453d7f6 +01-02-2025,10:02,01-02-2025,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Vente 17 iShares SMI Mid ETF CHF dis@106,02 CHF (CH0019852802)",,CHF,"1802,34",CHF,"43251,14",76e9d035-d4a9-433e-a616-d64c9453d7f6 +03-12-2024,22:21,03-12-2024,,,Degiro Cash Sweep Transfer,,EUR,"-8867,93",EUR,"12696,08", +03-12-2024,22:21,03-12-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 8 867,93 EUR",,,,EUR,"21564,01", +03-12-2024,20:35,03-12-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,30",EUR,"12696,08",f648447f-2b3c-4ecb-a5f9-3e03fb68bfa3 +03-12-2024,20:35,03-12-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,"Vente 191 SPDR S&P 500 Energy Sector UCITS ETF USD (Acc)@9,833 EUR (IE00BWBXM385)",,EUR,"1878,10",EUR,"12697,38",f648447f-2b3c-4ecb-a5f9-3e03fb68bfa3 +03-12-2024,20:34,03-12-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,06",EUR,"10819,28",229e34a0-a739-4336-a209-f89a8da0e707 +03-12-2024,20:34,03-12-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,"Vente 73 iShares Oil Equipment UCITS ETF@28,21 EUR (IE00B0H8QT01)",,EUR,"2059,33",EUR,"10820,34",229e34a0-a739-4336-a209-f89a8da0e707 +24-11-2024,15:23,24-11-2024,,,Degiro Cash Sweep Transfer,,EUR,"-7452,60",EUR,"8761,01", +24-11-2024,15:23,24-11-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 7 452,6 EUR",,,,EUR,"16213,61", +24-11-2024,14:26,24-11-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,31",EUR,"8761,01",60bbc054-61ec-4383-a91f-d956a2e68e69 +24-11-2024,14:26,24-11-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,"Vente 89 SPDR S&P 500 Energy Sector UCITS ETF USD (Acc)@9,606 EUR (IE00BWBXM385)",,EUR,"854,93",EUR,"8763,32",60bbc054-61ec-4383-a91f-d956a2e68e69 +22-11-2024,08:12,16-11-2024,,,Flatex Interest Income,,CHF,"0,00",CHF,"41448,80", +21-11-2024,22:28,16-11-2024,,,Flatex Interest Income,,EUR,"0,00",EUR,"7908,39", +19-11-2024,10:17,19-11-2024,,,Degiro Cash Sweep Transfer,,CHF,"-2617,96",CHF,"41448,80", +19-11-2024,10:17,19-11-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 2 617,96 CHF",,,,CHF,"44066,76", +19-11-2024,07:32,18-11-2024,,,Operation de change - Crédit,,CHF,"13,38",CHF,"41448,80", +19-11-2024,07:32,18-11-2024,,,Opération de change - Débit,"1,0701",USD,"-14,32",USD,"0,00", +18-11-2024,07:41,17-11-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Dividende,,USD,"14,32",USD,"14,32", +04-11-2024,10:42,04-11-2024,,,Degiro Cash Sweep Transfer,,CHF,"-944,82",CHF,"41435,42", +04-11-2024,10:42,04-11-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 944,82 CHF",,,,CHF,"42380,24", +04-11-2024,10:41,04-11-2024,,,Degiro Cash Sweep Transfer,,EUR,"7273,47",EUR,"7908,39", +04-11-2024,10:41,04-11-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 7 273,47 EUR",,,,EUR,"634,92", +04-11-2024,09:28,04-11-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,33",EUR,"7908,39",be791e8a-fa3f-4346-a803-b7d6610c343b +04-11-2024,09:28,04-11-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Vente 17 SPDR MSCI ACWI UCITS ETF USD Dis@106,03 CHF (IE00B44Z5B48)",,CHF,"1802,51",CHF,"41435,42",be791e8a-fa3f-4346-a803-b7d6610c343b +04-11-2024,09:26,04-11-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,58",EUR,"7909,72",13f0ec06-053c-4d21-ae8b-fe616cc5c75a +04-11-2024,09:26,04-11-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Vente 9 iShares SMI Mid ETF CHF dis@118,51 CHF (CH0019852802)",,CHF,"1066,59",CHF,"39632,91",13f0ec06-053c-4d21-ae8b-fe616cc5c75a +22-10-2024,18:52,22-10-2024,,,Degiro Cash Sweep Transfer,,CHF,"-1625,54",CHF,"38566,32", +22-10-2024,18:52,22-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 1 625,54 CHF",,,,CHF,"40191,86", +22-10-2024,18:51,22-10-2024,,,Degiro Cash Sweep Transfer,,EUR,"4819,24",EUR,"7912,30", +22-10-2024,18:51,22-10-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 4 819,24 EUR",,,,EUR,"3093,06", +22-10-2024,17:16,22-10-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,12",EUR,"7912,30",4176c22a-0555-48fa-a2eb-3cb4244678dd +22-10-2024,17:16,22-10-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Vente 18 SPDR MSCI ACWI UCITS ETF USD Dis@108,22 CHF (IE00B44Z5B48)",,CHF,"1947,96",CHF,"38566,32",4176c22a-0555-48fa-a2eb-3cb4244678dd +19-10-2024,20:54,19-10-2024,,,Degiro Cash Sweep Transfer,,CHF,"-2882,41",CHF,"36618,36", +19-10-2024,20:54,19-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 2 882,41 CHF",,,,CHF,"39500,77", +19-10-2024,20:52,19-10-2024,,,Degiro Cash Sweep Transfer,,EUR,"-192,13",EUR,"7913,42", +19-10-2024,20:52,19-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 192,13 EUR",,,,EUR,"8105,55", +19-10-2024,16:44,19-10-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-0,97",EUR,"7913,42",db8b71df-b74c-4159-ad11-cb5ce074d9a7 +19-10-2024,16:44,19-10-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,"Vente 35 iShares Oil Equipment UCITS ETF@20,69 EUR (IE00B0H8QT01)",,EUR,"724,15",EUR,"7914,39",db8b71df-b74c-4159-ad11-cb5ce074d9a7 +19-10-2024,16:41,19-10-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,99",EUR,"7190,24",43e28cbc-9667-44a0-afd1-f5b518372082 +19-10-2024,16:41,19-10-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Vente 56 SPDR MSCI ACWI UCITS ETF USD Dis@112,51 CHF (IE00B44Z5B48)",,CHF,"6300,56",CHF,"36618,36",43e28cbc-9667-44a0-afd1-f5b518372082 +19-10-2024,16:40,19-10-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,29",EUR,"7193,23",607d7229-8cc0-4630-a408-3fec168e7528 +19-10-2024,16:40,19-10-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Vente 21 iShares SMI Mid ETF CHF dis@120,5 CHF (CH0019852802)",,CHF,"2530,50",CHF,"30317,80",607d7229-8cc0-4630-a408-3fec168e7528 +15-10-2024,11:18,15-10-2024,,,Degiro Cash Sweep Transfer,,CHF,"-6580,72",CHF,"27787,30", +15-10-2024,11:18,15-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 6 580,72 CHF",,,,CHF,"34368,02", +15-10-2024,08:41,14-10-2024,,,Versement de fonds,,CHF,"4500,00",CHF,"27787,30", +12-10-2024,02:34,11-10-2024,,,Degiro Cash Sweep Transfer,,EUR,"385,07",EUR,"7194,52", +12-10-2024,02:34,11-10-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 385,07 EUR",,,,EUR,"6809,45", +11-10-2024,10:43,18-09-2024,,,Frais de connexion aux places boursières 2025 (- - FX),,EUR,"-2,50",EUR,"7194,52", +01-10-2024,14:31,01-10-2024,,,Degiro Cash Sweep Transfer,,CHF,"-2086,73",CHF,"23287,30", +01-10-2024,14:31,01-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 2 086,73 CHF",,,,CHF,"25374,03", +01-10-2024,14:30,01-10-2024,,,Degiro Cash Sweep Transfer,,EUR,"5338,64",EUR,"7197,02", +01-10-2024,14:30,01-10-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 5 338,64 EUR",,,,EUR,"1858,38", +01-10-2024,13:06,01-10-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,69",EUR,"7197,02",6d988645-592e-4da8-a742-7145e7051cc6 +01-10-2024,13:06,01-10-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Vente 41 SPDR MSCI ACWI UCITS ETF USD Dis@100,24 CHF (IE00B44Z5B48)",,CHF,"4109,84",CHF,"23287,30",6d988645-592e-4da8-a742-7145e7051cc6 +01-10-2024,10:41,01-10-2024,,,Degiro Cash Sweep Transfer,,CHF,"-5429,40",CHF,"19177,46", +01-10-2024,10:41,01-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 5 429,4 CHF",,,,CHF,"24606,86", +01-10-2024,10:40,01-10-2024,,,Degiro Cash Sweep Transfer,,EUR,"3826,25",EUR,"7199,71", +01-10-2024,10:40,01-10-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 3 826,25 EUR",,,,EUR,"3373,46", +01-10-2024,09:40,01-10-2024,,,Degiro Cash Sweep Transfer,,EUR,"-798,23",EUR,"7199,71", +01-10-2024,09:40,01-10-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 798,23 EUR",,,,EUR,"7997,94", +01-10-2024,09:01,01-10-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,10",EUR,"7199,71",abe9728d-e993-478b-a938-b9c547473c2f +01-10-2024,09:01,01-10-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Vente 21 iShares SMI Mid ETF CHF dis@97,66 CHF (CH0019852802)",,CHF,"2050,86",CHF,"19177,46",abe9728d-e993-478b-a938-b9c547473c2f +01-10-2024,09:01,01-10-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Vente 4 iShares SMI Mid ETF CHF dis@114,38 CHF (CH0019852802)",,CHF,"457,52",CHF,"17126,60",abe9728d-e993-478b-a938-b9c547473c2f +01-10-2024,08:20,01-10-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,27",EUR,"7200,81",bcbcb400-ff9e-48cf-a1d2-27fbb5ea6213 +01-10-2024,08:20,01-10-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,"Vente 52 iShares Oil Equipment UCITS ETF@25,9 EUR (IE00B0H8QT01)",,EUR,"1346,80",EUR,"7202,08",bcbcb400-ff9e-48cf-a1d2-27fbb5ea6213 +01-10-2024,08:14,01-10-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,05",EUR,"5855,28",36dbaad1-1cb4-4716-af57-53473f2dac59 +01-10-2024,08:14,01-10-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,"Vente 123 SPDR S&P 500 Energy Sector UCITS ETF USD (Acc)@9,977 EUR (IE00BWBXM385)",,EUR,"1227,17",EUR,"5857,33",36dbaad1-1cb4-4716-af57-53473f2dac59 +24-09-2024,09:51,24-09-2024,,,Degiro Cash Sweep Transfer,,CHF,"-538,91",CHF,"16669,08", +24-09-2024,09:51,24-09-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 538,91 CHF",,,,CHF,"17207,99", +24-09-2024,08:38,23-09-2024,,,Versement de fonds,,CHF,"2500,00",CHF,"16669,08", +23-09-2024,11:41,23-09-2024,,,Degiro Cash Sweep Transfer,,CHF,"-4235,96",CHF,"14169,08", +23-09-2024,11:41,23-09-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 4 235,96 CHF",,,,CHF,"18405,04", +23-09-2024,08:38,22-09-2024,,,Versement de fonds,,CHF,"2000,00",CHF,"14169,08", +21-09-2024,11:31,21-09-2024,,,Degiro Cash Sweep Transfer,,CHF,"-5640,83",CHF,"12169,08", +21-09-2024,11:31,21-09-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 5 640,83 CHF",,,,CHF,"17809,91", +21-09-2024,08:40,20-09-2024,,,Versement de fonds,,CHF,"500,00",CHF,"12169,08", +27-08-2024,01:10,20-08-2024,,,Flatex Interest Income,,EUR,"0,00",EUR,"4630,16", +24-08-2024,07:00,25-08-2024,EUR/CHF,,Opération de change - Débit,,CHF,"308,31",CHF,"11669,08",4bfe7ac9-f152-48be-ad0f-c469eb1cc9ee +24-08-2024,07:00,25-08-2024,EUR/CHF,,"Règlement transaction devise: Vente 4 800 EUR/CHF@0,9412 CHF ()",,CHF,"4517,76",CHF,"11360,77",4bfe7ac9-f152-48be-ad0f-c469eb1cc9ee +23-08-2024,15:43,23-08-2024,,,Degiro Cash Sweep Transfer,,EUR,"-2831,79",EUR,"4630,16", +23-08-2024,15:43,23-08-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 2 831,79 EUR",,,,EUR,"7461,95", +23-08-2024,14:51,23-08-2024,,,Degiro Cash Sweep Transfer,,CHF,"6083,49",CHF,"6843,01", +23-08-2024,14:51,23-08-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 6 083,49 CHF",,,,CHF,"759,52", +23-08-2024,14:07,23-08-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-2,99",EUR,"4630,16",f9dd5b47-62b3-499d-ad5e-6266132dee64 +23-08-2024,14:07,23-08-2024,SPDR S&P 500 ENERGY SECTOR UCITS,IE00BWBXM385,"Achat 540 SPDR S&P 500 Energy Sector UCITS ETF USD (Acc)@10,396 EUR (IE00BWBXM385)",,EUR,"-5613,84",EUR,"4633,15",f9dd5b47-62b3-499d-ad5e-6266132dee64 +23-08-2024,14:04,23-08-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,Frais DEGIRO de courtage et/ou de parties tierces,,EUR,"-1,09",EUR,"10246,99",65f57a6d-fcc4-4148-a572-c8b1b3d0fce0 +23-08-2024,14:04,23-08-2024,ISHARES OIL EQUIPMENT UCITS ETF,IE00B0H8QT01,"Achat 144 iShares Oil Equipment UCITS ETF@27,71 EUR (IE00B0H8QT01)",,EUR,"-3990,24",EUR,"10248,08",65f57a6d-fcc4-4148-a572-c8b1b3d0fce0 +23-08-2024,13:52,23-08-2024,EUR/CHF,,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,58",CHF,"6843,01",4bfe7ac9-f152-48be-ad0f-c469eb1cc9ee +23-08-2024,13:52,23-08-2024,EUR/CHF,,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-0,92",CHF,"6845,59",4bfe7ac9-f152-48be-ad0f-c469eb1cc9ee +23-08-2024,13:52,23-08-2024,EUR/CHF,,Operation de change - Crédit,"1,0914",EUR,"0,85",EUR,"14238,32",4bfe7ac9-f152-48be-ad0f-c469eb1cc9ee +23-08-2024,13:52,23-08-2024,EUR/CHF,,"Achat 4 800 EUR/CHF@0,9412 CHF ()",,CHF,"-4517,76",CHF,"6846,51",4bfe7ac9-f152-48be-ad0f-c469eb1cc9ee +22-08-2024,13:50,20-08-2024,,,Flatex Interest Income,,CHF,"0,00",CHF,"11364,27", +21-08-2024,07:49,20-08-2024,,,Operation de change - Crédit,,CHF,"13,57",CHF,"11364,27", +21-08-2024,07:49,20-08-2024,,,Opération de change - Débit,"1,1389",USD,"-15,46",USD,"0,00", +20-08-2024,07:47,18-08-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Dividende,,USD,"15,46",USD,"15,46", +28-05-2024,13:10,18-05-2024,,,Flatex Interest Income,,CHF,"0,00",CHF,"11350,70", +27-05-2024,03:10,18-05-2024,,,Flatex Interest Income,,EUR,"0,00",EUR,"14237,47", +21-05-2024,18:43,21-05-2024,,,Degiro Cash Sweep Transfer,,CHF,"-7258,78",CHF,"11350,70", +21-05-2024,18:43,21-05-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 7 258,78 CHF",,,,CHF,"18609,48", +21-05-2024,08:06,20-05-2024,,,Operation de change - Crédit,,CHF,"35,95",CHF,"11350,70", +21-05-2024,08:06,20-05-2024,,,Opération de change - Débit,"1,1857",USD,"-42,62",USD,"0,00", +20-05-2024,07:34,19-05-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Dividende,,USD,"42,62",USD,"42,62", +19-05-2024,13:30,19-05-2024,,,Degiro Cash Sweep Transfer,,CHF,"6837,73",CHF,"11314,75", +19-05-2024,13:30,19-05-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 6 837,73 CHF",,,,CHF,"4477,02", +19-05-2024,12:33,19-05-2024,,,Degiro Cash Sweep Transfer,,CHF,"-6667,38",CHF,"11314,75", +19-05-2024,12:33,19-05-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 6 667,38 CHF",,,,CHF,"17982,13", +19-05-2024,10:00,19-05-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,01",CHF,"11314,75",8fba068c-e3e0-4be1-a550-0ac437aa6eeb +19-05-2024,10:00,19-05-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 12 iShares SMI Mid ETF CHF dis@105,04 CHF (CH0019852802)",,CHF,"-1260,48",CHF,"11315,76",8fba068c-e3e0-4be1-a550-0ac437aa6eeb +19-05-2024,08:41,18-05-2024,,,Versement de fonds,,CHF,"4500,00",CHF,"12576,24", +28-04-2024,09:02,28-04-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 1 477,82 CHF",,,,CHF,"8076,24", +28-04-2024,09:02,28-04-2024,,,Degiro Cash Sweep Transfer,,CHF,"-1477,82",CHF,"6598,42", +28-04-2024,07:50,27-04-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Remboursement de capital,,CHF,"13,65",CHF,"8076,24", +28-04-2024,07:49,27-04-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Impôts sur dividende,,CHF,"-20,77",CHF,"8062,59", +28-04-2024,07:49,27-04-2024,ISHARES SMI MID ETF CHF DIS,CH0019852802,Dividende,,CHF,"117,50",CHF,"8083,36", +22-02-2024,20:01,16-02-2024,,,Flatex Interest Income,,CHF,"0,00",CHF,"7965,86", +22-02-2024,06:42,16-02-2024,,,Flatex Interest Income,,EUR,"0,00",EUR,"14237,47", +20-02-2024,12:01,20-02-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 7 137,88 CHF",,,,CHF,"7965,86", +20-02-2024,12:01,20-02-2024,,,Degiro Cash Sweep Transfer,,CHF,"-7137,88",CHF,"827,98", +20-02-2024,07:20,19-02-2024,,,Operation de change - Crédit,,CHF,"105,95",CHF,"7965,86", +20-02-2024,07:20,19-02-2024,,,Opération de change - Débit,"1,0607",USD,"-112,38",USD,"0,00", +19-02-2024,07:26,18-02-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Dividende,,USD,"112,38",USD,"112,38", +06-02-2024,11:20,06-02-2024,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 1 133,99 CHF",,,,CHF,"7859,91", +06-02-2024,11:20,06-02-2024,,,Degiro Cash Sweep Transfer,,CHF,"1133,99",CHF,"8993,90", +06-02-2024,10:20,06-02-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,30",CHF,"7859,91",065791e8-c364-4363-ae7e-2cf97d9d2fa6 +06-02-2024,10:20,06-02-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 63 SPDR MSCI ACWI UCITS ETF USD Dis@112,62 CHF (IE00B44Z5B48)",,CHF,"-7095,06",CHF,"7861,21",065791e8-c364-4363-ae7e-2cf97d9d2fa6 +04-02-2024,17:20,04-02-2024,,,Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 6 048 CHF,,,,CHF,"14956,27", +04-02-2024,17:20,04-02-2024,,,Degiro Cash Sweep Transfer,,CHF,"6048,00",CHF,"21004,27", +04-02-2024,16:31,04-02-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,93",CHF,"14956,27",4ba56d86-edb9-42ef-a592-41eea17db970 +04-02-2024,16:31,04-02-2024,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 30 SPDR MSCI ACWI UCITS ETF USD Dis@100,49 CHF (IE00B44Z5B48)",,CHF,"-3014,70",CHF,"14958,20",4ba56d86-edb9-42ef-a592-41eea17db970 +04-02-2024,09:32,04-02-2024,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 8 074,55 CHF",,,,CHF,"17972,90", +04-02-2024,09:32,04-02-2024,,,Degiro Cash Sweep Transfer,,CHF,"-8074,55",CHF,"9898,35", +04-02-2024,08:36,03-02-2024,,,Versement de fonds,,CHF,"1000,00",CHF,"17972,90", +25-11-2023,13:40,25-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 1 692,77 CHF",,,,CHF,"16972,90", +25-11-2023,13:40,25-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"-1894,97",CHF,"18665,67", +25-11-2023,12:30,25-11-2023,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 1 894,97 CHF",,,,CHF,"20560,64", +25-11-2023,12:30,25-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"0,00",CHF,"18665,67", +25-11-2023,09:27,25-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,43",CHF,"18665,67",42c4dd48-82d0-4e47-af39-fdc9537c594b +25-11-2023,09:27,25-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 32 iShares SMI Mid ETF CHF dis@125,81 CHF (CH0019852802)",,CHF,"-4025,92",CHF,"18668,10",42c4dd48-82d0-4e47-af39-fdc9537c594b +25-11-2023,09:26,25-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,95",CHF,"22694,02",492acebd-9769-4fcb-a0e8-a3e58717b18e +25-11-2023,09:26,25-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 7 SPDR MSCI ACWI UCITS ETF USD Dis@116,16 CHF (IE00B44Z5B48)",,CHF,"-813,12",CHF,"22696,97",492acebd-9769-4fcb-a0e8-a3e58717b18e +25-11-2023,09:24,25-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,88",CHF,"23510,09",0fd9979d-bf43-4a7d-adbf-384157aeb4a4 +25-11-2023,09:24,25-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 20 SPDR MSCI ACWI UCITS ETF USD Dis@112,55 CHF (IE00B44Z5B48)",,CHF,"-2251,00",CHF,"23512,97",0fd9979d-bf43-4a7d-adbf-384157aeb4a4 +25-11-2023,08:47,24-11-2023,,,Versement de fonds,,CHF,"3500,00",CHF,"25763,97", +25-11-2023,01:45,24-11-2023,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 3 545,8 CHF",,,,CHF,"22263,97", +25-11-2023,01:45,24-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"-3545,80",CHF,"18718,17", +24-11-2023,20:12,24-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 1 317,5 CHF",,,,CHF,"22263,97", +24-11-2023,20:12,24-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"6668,34",CHF,"23581,47", +24-11-2023,19:42,24-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 6 668,34 CHF",,,,CHF,"16913,13", +24-11-2023,19:42,24-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"-5002,07",CHF,"23581,47", +24-11-2023,19:31,24-11-2023,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 5 002,07 CHF",,,,CHF,"28583,54", +24-11-2023,19:31,24-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"0,00",CHF,"23581,47", +24-11-2023,15:24,24-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,20",CHF,"23581,47",0fe98554-44a8-4ec7-a93b-d00984457929 +24-11-2023,15:24,24-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 5 iShares SMI Mid ETF CHF dis@94,04 CHF (CH0019852802)",,CHF,"-470,20",CHF,"23583,67",0fe98554-44a8-4ec7-a93b-d00984457929 +24-11-2023,15:06,24-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"1,36",CHF,"24053,87",d4eb0f91-9119-4fab-ac7e-8f94b72c3735 +24-11-2023,15:05,24-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Vente 6 iShares SMI Mid ETF CHF dis@113,33 CHF (CH0019852802)",,CHF,"679,98",CHF,"24052,51",d4eb0f91-9119-4fab-ac7e-8f94b72c3735 +24-11-2023,10:09,24-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,48",CHF,"23372,53",5ed6d51a-0167-4f92-ad48-2c3c1dc628b9 +24-11-2023,10:09,24-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 1 SPDR MSCI ACWI UCITS ETF USD Dis@97,9 CHF (IE00B44Z5B48)",,CHF,"-97,90",CHF,"23375,01",5ed6d51a-0167-4f92-ad48-2c3c1dc628b9 +24-11-2023,09:25,24-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,88",CHF,"23472,91",355d335a-6fbd-40b3-adee-b3d3460f3a62 +24-11-2023,09:25,24-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 7 SPDR MSCI ACWI UCITS ETF USD Dis@113,03 CHF (IE00B44Z5B48)",,CHF,"-791,21",CHF,"23475,79",355d335a-6fbd-40b3-adee-b3d3460f3a62 +24-11-2023,09:23,24-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,83",CHF,"24267,00",d4eb0f91-9119-4fab-ac7e-8f94b72c3735 +24-11-2023,09:23,24-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 4 iShares SMI Mid ETF CHF dis@117,93 CHF (CH0019852802)",,CHF,"-471,72",CHF,"24269,83",d4eb0f91-9119-4fab-ac7e-8f94b72c3735 +24-11-2023,08:40,21-11-2023,,,Versement de fonds,,CHF,"4000,00",CHF,"24741,55", +23-11-2023,20:30,17-11-2023,,,Flatex Interest Income,,EUR,"0,00",EUR,"14237,47", +23-11-2023,08:12,17-11-2023,,,Flatex Interest Income,,CHF,"0,00",CHF,"20741,55", +21-11-2023,10:25,21-11-2023,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 2 283,78 CHF",,,,CHF,"20741,55", +21-11-2023,10:25,21-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"-2283,78",CHF,"18457,77", +21-11-2023,07:18,20-11-2023,,,Operation de change - Crédit,,CHF,"80,56",CHF,"20741,55", +21-11-2023,07:18,20-11-2023,,,Opération de change - Débit,"1,0102",USD,"-81,38",USD,"0,00", +20-11-2023,08:20,20-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 31,46 CHF",,,,CHF,"20660,99", +20-11-2023,08:20,20-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"31,46",CHF,"20692,45", +20-11-2023,07:27,19-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Dividende,,USD,"81,38",USD,"81,38", +20-11-2023,06:36,19-11-2023,,,Opération de change - Débit,,CHF,"27,38",CHF,"20660,99", +20-11-2023,06:36,19-11-2023,,,Operation de change - Crédit,"1,046",EUR,"282,56",EUR,"14237,47", +19-11-2023,13:02,17-11-2023,,,Frais de connexion aux places boursières 2024 (Euronext Amsterdam - EAM),,EUR,"-2,50",EUR,"13954,91", +11-11-2023,12:40,11-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 6 307,4 CHF",,,,CHF,"20633,61", +11-11-2023,12:40,11-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"6307,40",CHF,"26941,01", +11-11-2023,11:40,11-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,38",CHF,"20633,61",e3d2268e-1256-4ff3-a1cc-bd23c07d79f9 +11-11-2023,11:40,11-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 10 iShares SMI Mid ETF CHF dis@110,05 CHF (CH0019852802)",,CHF,"-1100,50",CHF,"20635,99",e3d2268e-1256-4ff3-a1cc-bd23c07d79f9 +11-11-2023,10:40,11-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 7 202,71 CHF",,,,CHF,"21736,49", +11-11-2023,10:40,11-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"7202,71",CHF,"28939,20", +11-11-2023,09:27,11-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,93",CHF,"21736,49",5ccbb862-4964-4f75-af96-83339e51c3b6 +11-11-2023,09:27,11-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 20 SPDR MSCI ACWI UCITS ETF USD Dis@95,66 CHF (IE00B44Z5B48)",,CHF,"-1913,20",CHF,"21738,42",5ccbb862-4964-4f75-af96-83339e51c3b6 +10-11-2023,11:40,10-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 6 334,34 CHF",,,,CHF,"23651,62", +10-11-2023,11:40,10-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"6334,34",CHF,"29985,96", +10-11-2023,10:36,10-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,16",CHF,"23651,62",fa3782df-f925-453c-ac72-eef6ed46e81f +10-11-2023,10:36,10-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 15 iShares SMI Mid ETF CHF dis@117,82 CHF (CH0019852802)",,CHF,"-1767,30",CHF,"23652,78",fa3782df-f925-453c-ac72-eef6ed46e81f +06-11-2023,10:50,06-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 5 227,31 CHF",,,,CHF,"25420,08", +06-11-2023,10:50,06-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"5227,31",CHF,"30647,39", +06-11-2023,09:01,06-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,30",CHF,"25420,08",4d9a636a-ab2b-4c83-aaff-d9c3d95a2117 +06-11-2023,09:01,06-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 1 iShares SMI Mid ETF CHF dis@124,06 CHF (CH0019852802)",,CHF,"-124,06",CHF,"25421,38",4d9a636a-ab2b-4c83-aaff-d9c3d95a2117 +06-11-2023,09:01,06-11-2023,ISHARES SMI MID ETF CHF DIS,CH0019852802,"Achat 7 iShares SMI Mid ETF CHF dis@110,45 CHF (CH0019852802)",,CHF,"-773,15",CHF,"25545,44",4d9a636a-ab2b-4c83-aaff-d9c3d95a2117 +05-11-2023,12:50,05-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 149,05 CHF",,,,CHF,"26318,59", +05-11-2023,12:50,05-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"149,05",CHF,"26467,64", +05-11-2023,11:20,05-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,78",CHF,"26318,59",3a331019-befe-4705-a654-1bbc0b46ac5f +05-11-2023,11:20,05-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 19 SPDR MSCI ACWI UCITS ETF USD Dis@113,6 CHF (IE00B44Z5B48)",,CHF,"-2158,40",CHF,"26320,37",3a331019-befe-4705-a654-1bbc0b46ac5f +05-11-2023,11:19,05-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Opération de change - Débit,"1,0576",EUR,"-3979,44",EUR,"13957,41",1d73c9ea-1d6a-4157-aa67-6e7c34c2553d +05-11-2023,11:19,05-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Operation de change - Crédit,,CHF,"346,74",CHF,"28478,77",1d73c9ea-1d6a-4157-aa67-6e7c34c2553d +05-11-2023,11:19,05-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,91",CHF,"28132,03",1d73c9ea-1d6a-4157-aa67-6e7c34c2553d +05-11-2023,11:19,05-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Vente 11 SPDR MSCI ACWI UCITS ETF USD Dis@109,54 EUR (IE00B44Z5B48)",,EUR,"1204,94",EUR,"17936,85",1d73c9ea-1d6a-4157-aa67-6e7c34c2553d +04-11-2023,10:50,04-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 5 190,47 CHF",,,,CHF,"28134,94", +04-11-2023,10:50,04-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"5190,47",CHF,"33325,41", +04-11-2023,09:23,04-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-2,36",CHF,"28134,94",dd22f039-ec15-4bae-aec1-0189de78e8dd +04-11-2023,09:23,04-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 9 SPDR MSCI ACWI UCITS ETF USD Dis@127,41 CHF (IE00B44Z5B48)",,CHF,"-1146,69",CHF,"28137,30",dd22f039-ec15-4bae-aec1-0189de78e8dd +03-11-2023,10:51,03-11-2023,,,"Virement depuis votre Compte Espèces à la flatexDEGIRO Bank: 3 792,31 CHF",,,,CHF,"29283,99", +03-11-2023,10:51,03-11-2023,,,Degiro Cash Sweep Transfer,,CHF,"3792,31",CHF,"33076,30", +03-11-2023,09:57,03-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Operation de change - Crédit,"1,0435",EUR,"3655,94",EUR,"16731,91",9d858849-545b-4134-a90b-6d81cf7422ce +03-11-2023,09:57,03-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Opération de change - Débit,,CHF,"45,17",CHF,"29283,99",9d858849-545b-4134-a90b-6d81cf7422ce +03-11-2023,09:57,03-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,Frais DEGIRO de courtage et/ou de parties tierces,,CHF,"-1,72",CHF,"29238,82",9d858849-545b-4134-a90b-6d81cf7422ce +03-11-2023,09:57,03-11-2023,SPDR MSCI ACWI UCITS ETF,IE00B44Z5B48,"Achat 7 SPDR MSCI ACWI UCITS ETF USD Dis@125,92 EUR (IE00B44Z5B48)",,EUR,"-881,44",EUR,"13075,97",9d858849-545b-4134-a90b-6d81cf7422ce +31-10-2023,09:52,31-10-2023,,,"Virement vers votre Compte Espèces à la flatexDEGIRO Bank: 7 894,62 CHF",,,,CHF,"29240,54", +31-10-2023,09:52,31-10-2023,,,Degiro Cash Sweep Transfer,,CHF,"-7894,62",CHF,"21345,92", +31-10-2023,08:37,30-10-2023,,,Versement de fonds,,CHF,"2000,00",CHF,"29240,54", diff --git a/test/integration.test.ts b/test/integration.test.ts index 44e70bf..ef1a678 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -18,6 +18,11 @@ const fixture = readFileSync( 'utf8', ); +const englishFixture = readFileSync( + fileURLToPath(new URL('./fixtures/Account-en.csv', import.meta.url)), + 'utf8', +); + describe('end-to-end on the real sample export', () => { const result = parseDegiroCsv(fixture); @@ -37,18 +42,127 @@ describe('end-to-end on the real sample export', () => { }); }); +describe('end-to-end on an English-header export', () => { + const result = parseDegiroCsv(englishFixture); + + it('detects the English dialect and parses it cleanly', () => { + expect(result.dialect.id).toBe('en'); + expect(result.errors).toHaveLength(0); + expect(result.records).toHaveLength(236); + expect(result.movements.every((m) => m.kind !== 'unknown')).toBe(true); + expect(reconcileBalances(result.movements).ok).toBe(true); + }); + + it('yields the same movements as the French export of the same account', () => { + const french = parseDegiroCsv(fixture); + const shape = (r: typeof result) => + r.movements.map((m) => ({ + kind: m.kind, + amount: m.amount?.toString() ?? null, + description: m.record.description, + bookingDate: m.record.bookingDate.toISOString(), + })); + expect(shape(result)).toEqual(shape(french)); + }); + + it('summarizes to the same portfolio as the French export', () => { + const english = summarizePortfolio(result.movements); + const french = summarizePortfolio(parseDegiroCsv(fixture).movements); + expect(english.cashByCurrency.map((m) => m.toString())).toEqual( + french.cashByCurrency.map((m) => m.toString()), + ); + expect(english.positions.map((p) => p.isin)).toEqual(french.positions.map((p) => p.isin)); + }); +}); + +describe('an English export written in English, with US number formatting', () => { + const csv = [ + 'Date,Time,Value date,Product,ISIN,Description,FX,Change,,Balance,,Order Id', + '20-11-2024,09:01,20-11-2024,SMI ETF,CH0019852802,"Buy 1,060 SMI ETF@106.02 CHF (CH0019852802)",,CHF,"-112381.20",CHF,"7,939.80",o1', + '20-11-2024,09:01,20-11-2024,SMI ETF,CH0019852802,DEGIRO Transaction and/or Third Party Fees,,CHF,"-2.79",CHF,"7,937.01",o1', + '19-11-2024,09:01,19-11-2024,VWCE,IE00BK5BQT80,"Sell 10 VWCE@120.36 EUR (IE00BK5BQT80)",,EUR,"1,203.60",EUR,"1,203.60",o2', + '18-11-2024,00:00,18-11-2024,VWCE,IE00BK5BQT80,Dividend,,EUR,"12.34",EUR,"12.34",', + '18-11-2024,00:00,18-11-2024,VWCE,IE00BK5BQT80,Dividend Tax,,EUR,"-1.85",EUR,"10.49",', + '17-11-2024,00:00,17-11-2024,,,Currency Exchange - Credit,1.0888,CHF,"500.00",CHF,"500.00",o3', + '17-11-2024,00:00,17-11-2024,,,FX Debit,1.0888,EUR,"-459.22",EUR,"0.00",o3', + '16-11-2024,00:00,16-11-2024,,,Degiro Cash Sweep Transfer,,CHF,"9,000.00",CHF,"9,000.00",', + '16-11-2024,00:00,16-11-2024,,,"Transfer from your Cash Account at flatex Bank: 9,000.00 CHF",,,,CHF,"0.00",', + '15-11-2024,00:00,15-11-2024,,,Deposit,,CHF,"9,000.00",CHF,"9,000.00",', + '14-11-2024,00:00,14-11-2024,,,Processed Flatex Withdrawal,,CHF,"-100.00",CHF,"8,900.00",', + '13-11-2024,00:00,13-11-2024,,,DEGIRO Exchange Connection Fee 2024 (Euronext Amsterdam - EAM),,EUR,"-2.50",EUR,"-2.50",', + '12-11-2024,00:00,12-11-2024,,,Flatex Interest Income,,EUR,"0.42",EUR,"0.42",', + '11-11-2024,00:00,11-11-2024,,,Return of Capital,,EUR,"5.00",EUR,"5.00",', + '10-11-2024,00:00,10-11-2024,,,"Currency transaction settlement: Sell 1,900 EUR/CHF@0.9412 CHF ()",,EUR,"-1,900.00",EUR,"0.00",o4', + '', + ].join('\n'); + + const result = parseDegiroCsv(csv); + + it('classifies every English description with the built-in matchers', () => { + expect(result.dialect.id).toBe('en'); + expect(result.issues).toHaveLength(0); + expect(result.movements.map((m) => m.kind)).toEqual([ + 'buy', + 'brokerageFee', + 'sell', + 'dividend', + 'dividendTax', + 'fxCredit', + 'fxDebit', + 'cashSweep', + 'cashTransfer', + 'deposit', + 'withdrawal', + 'connectivityFee', + 'interest', + 'capitalReturn', + 'fxTrade', + ]); + }); + + it('reads US-grouped quantities and prices', () => { + const buy = result.movements.find((m) => m.kind === 'buy'); + expect(buy?.kind).toBe('buy'); + if (buy?.kind === 'buy') { + expect(buy.quantity).toBe(1060); + expect(buy.unitPrice?.toString()).toBe('106.02 CHF'); + expect(buy.amount?.toString()).toBe('-112381.2 CHF'); + } + }); + + it('reads the direction and stated amount of an English cash transfer', () => { + const transfer = result.movements.find((m) => m.kind === 'cashTransfer'); + expect(transfer?.kind).toBe('cashTransfer'); + if (transfer?.kind === 'cashTransfer') { + expect(transfer.direction).toBe('fromCashAccount'); + expect(transfer.statedAmount?.toString()).toBe('9000 CHF'); + } + }); + + it('reads the settlement leg of an English FX trade', () => { + const fx = result.movements.find((m) => m.kind === 'fxTrade'); + expect(fx?.kind).toBe('fxTrade'); + if (fx?.kind === 'fxTrade') { + expect(fx.settlement).toBe(true); + expect(fx.pair).toBe('EUR/CHF'); + expect(fx.quantity).toBe(1900); + expect(fx.rate?.toString()).toBe('0.9412 CHF'); + } + }); +}); + describe('extensibility: a custom dialect and custom matchers', () => { - // A different locale: English headers, US number format (comma thousands, dot decimal). - const englishDialect: Dialect = { - id: 'en', - label: 'DEGIRO English (custom)', + // A locale libdegiro does not ship: Dutch headers, dot thousands, comma decimals. + const dutchDialect: Dialect = { + id: 'nl', + label: 'DEGIRO Dutch (custom)', columns: frenchDialect.columns, matches: (header) => - ['Date', 'Time', 'Product', 'ISIN', 'Change', 'Balance'].every((t) => + ['Datum', 'Tijd', 'Product', 'ISIN', 'Omschrijving', 'Mutatie', 'Saldo'].every((t) => header.map((c) => c.trim()).includes(t), ), parseDecimal: (raw) => { - const normalized = raw.trim().replace(/,/g, ''); + const normalized = raw.trim().replace(/\./g, '').replace(',', '.'); if (normalized === '') return null; return /^-?\d+(\.\d+)?$/.test(normalized) ? normalized : null; }, @@ -56,18 +170,18 @@ describe('extensibility: a custom dialect and custom matchers', () => { parseDate: (date) => parseFrenchDateTime(date), }; - const TRADE = /^(Buy|Sell)\s+(\d+)\s+.*@([\d.]+)\s+([A-Z]{3})\s+\(([^)]*)\)$/; - const englishTradeMatcher: Matcher = { - name: 'en-trade', + const TRADE = /^(Koop|Verkoop)\s+(\d+)\s+.*@([\d,]+)\s+([A-Z]{3})\s+\(([^)]*)\)$/; + const dutchTradeMatcher: Matcher = { + name: 'nl-trade', match({ record }) { const m = TRADE.exec(record.description.trim()); if (!m) return null; - const side = m[1] === 'Buy' ? 'buy' : 'sell'; + const side = m[1] === 'Koop' ? 'buy' : 'sell'; return { kind: side, side, quantity: Number(m[2]), - unitPrice: new Money(m[3]!, m[4]!), + unitPrice: new Money(m[3]!.replace(',', '.'), m[4]!), product: record.product, isin: record.isin ?? (m[5] || null), orderId: record.orderId, @@ -76,29 +190,29 @@ describe('extensibility: a custom dialect and custom matchers', () => { }; }, }; - const englishDepositMatcher: Matcher = { - name: 'en-deposit', + const dutchDepositMatcher: Matcher = { + name: 'nl-deposit', match({ record }) { - if (record.description.trim() !== 'Deposit') return null; + if (record.description.trim() !== 'Storting') return null; return { kind: 'deposit', amount: record.mutation, record }; }, }; - const englishCsv = [ - 'Date,Time,Value date,Product,ISIN,Description,FX,Change,,Balance,,Order Id', - '20-11-2024,09:01,20-11-2024,SMI,CH0019852802,"Buy 10 SMI@106.02 CHF (CH0019852802)",,CHF,"-1,060.20",CHF,"7,939.80",o1', - '14-11-2024,08:37,13-11-2024,,,Deposit,,CHF,"9,000.00",CHF,"9,000.00",', + const dutchCsv = [ + 'Datum,Tijd,Valutadatum,Product,ISIN,Omschrijving,FX,Mutatie,,Saldo,,Order Id', + '20-11-2024,09:01,20-11-2024,SMI,CH0019852802,"Koop 10 SMI@106,02 CHF (CH0019852802)",,CHF,"-1.060,20",CHF,"7.939,80",o1', + '14-11-2024,08:37,13-11-2024,,,Storting,,CHF,"9.000,00",CHF,"9.000,00",', '', ].join('\n'); it('parses a foreign-format export via injected dialect + classifier', () => { - const classifier = new ClassifierRegistry([englishTradeMatcher, englishDepositMatcher]); - const result = parseDegiroCsv(englishCsv, { - dialects: [englishDialect], + const classifier = new ClassifierRegistry([dutchTradeMatcher, dutchDepositMatcher]); + const result = parseDegiroCsv(dutchCsv, { + dialects: [dutchDialect], classifier, }); - expect(result.dialect.id).toBe('en'); + expect(result.dialect.id).toBe('nl'); expect(result.movements).toHaveLength(2); const buy = result.movements.find((m) => m.kind === 'buy'); From bc05823027f7f60dadb0c49ad4b546db7e060bb9 Mon Sep 17 00:00:00 2001 From: nyg Date: Sun, 23 Aug 2026 09:51:20 +0200 Subject: [PATCH 2/3] feat: fall back to DEGIRO's column layout when the header language is unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 6 +- README.md | 37 ++++++-- .../src/components/sections/health.tsx | 7 +- .../dashboard/src/lib/analytics/health.ts | 25 ++++- examples/dashboard/test/positions.test.ts | 22 +++++ src/classify/descriptions.ts | 51 +++++++--- src/classify/index.ts | 1 + src/classify/matchers/structural.ts | 63 +++++++++++++ src/classify/registry.ts | 3 + src/dialects/common.ts | 59 +++++++++--- src/dialects/en.ts | 28 ++---- src/dialects/generic.ts | 29 ++++++ src/dialects/index.ts | 1 + src/dialects/registry.ts | 3 +- src/dialects/types.ts | 1 + src/internal.ts | 14 ++- src/io/stream.ts | 2 + src/parse.ts | 20 +++- test/dialect.test.ts | 94 ++++++++++++++++--- test/integration.test.ts | 78 +++++++++++++++ 20 files changed, 463 insertions(+), 81 deletions(-) create mode 100644 src/classify/matchers/structural.ts create mode 100644 src/dialects/generic.ts diff --git a/AGENTS.md b/AGENTS.md index fd2c6f1..66d9495 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,9 +33,9 @@ Run `pnpm lint && pnpm typecheck && pnpm test` before proposing a change; that i ``` src/ csv/ tokenizer (papaparse) + Node row stream + shared options - dialects/ Dialect interface, French and English dialects, DialectRegistry + dialects/ Dialect interface, French/English/layout-fallback dialects, DialectRegistry records/ RawRecord — a normalised, dialect-agnostic row - classify/ Matcher registry + matchers (trade, fx, dividend, fees, interest, cash) + classify/ Matcher registry + matchers (trade, fx, dividend, fees, interest, cash, structural) group/ GroupingStrategy pipeline + Transaction union validate/ per-currency balance reconciliation portfolio/ positions, FIFO realized P/L, cash and fee roll-ups @@ -54,6 +54,8 @@ examples/dashboard/ The pipeline is `tokenizeCsv` → `mapRow` (dialect) → `ClassifierRegistry.classify` → `groupMovements` (strategy list) → optional `reconcileBalances` / `summarizePortfolio`. Every stage is swappable through `ParseOptions`; adding a locale, a description or a grouping rule should mean registering something, never editing a `switch`. +Header language, number format and description language are three independent axes of a DEGIRO export — changing the interface language rewrites line 1 and nothing else. Keep them independent in code: dialects own the header and the number/date formats, matchers own the wording, and neither may assume the other. `genericDialect` closes the set by matching the column layout alone; anything heuristic sets `heuristic: true` so `ParseResult.warnings` can say so. + ## Rules that are load-bearing - **The root entry imports no Node builtin.** `src/index.ts` and everything it reaches must stay free of `node:*`; anything needing a filesystem or streams goes behind `src/node.ts`. `test/entrypoints.test.ts` guards this and has caught a regression before. diff --git a/README.md b/README.md index 21a1e6b..53c37bd 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ A modern, ESM-only TypeScript library that turns a DEGIRO account statement into - 🧮 **Exact money** with [`big.js`](https://github.com/MikeMcl/big.js) — no float drift - 🧩 **Extensible** dialects, classifiers and grouping strategies -- 🌐 **French and English exports** out of the box, whatever number format they carry +- 🌐 **French and English exports** out of the box, and a layout-detecting fallback for every other language - 🧠 **Typed domain model** — discriminated unions for movements & transactions - 🪶 **Lenient parsing** — per-row problems are collected, never thrown - 🌊 **Streaming** parser for very large files @@ -111,17 +111,38 @@ Parsing is **lenient**: a row with an unparseable date is dropped and reported a ## Supported exports -DEGIRO localizes the **header row** of `Account.csv` to the interface language, and a built-in dialect recognises it: +DEGIRO localizes the **header row** of `Account.csv` to the interface language. Two dialects recognise a header by its words, and a third recognises the file by its shape when neither does: -| Dialect | `id` | Header | -| ---------------- | ---- | --------------------------------------------------------------------------------- | -| `frenchDialect` | `fr` | `Date,Heure,Date de,Produit,Code ISIN,Description,FX,Mouvements,,Solde,,ID Ordre` | -| `englishDialect` | `en` | `Date,Time,Value date,Product,ISIN,Description,FX,Change,,Balance,,Order Id` | +| Dialect | `id` | Header | +| ---------------- | --------- | --------------------------------------------------------------------------------- | +| `frenchDialect` | `fr` | `Date,Heure,Date de,Produit,Code ISIN,Description,FX,Mouvements,,Solde,,ID Ordre` | +| `englishDialect` | `en` | `Date,Time,Value date,Product,ISIN,Description,FX,Change,,Balance,,Order Id` | +| `genericDialect` | `generic` | any header in DEGIRO's column layout, whatever the labels say | -Both share the positional column layout and `DD-MM-YYYY` dates. They differ in how they read numbers: `frenchDialect` expects space thousands separators and a comma decimal mark, while `englishDialect` accepts either that or the US `1,060.20` form — DEGIRO does **not** switch the body of the file to English number formatting when you switch the interface language, so an English export can carry either. When a value contains both `,` and `.`, the last one is the decimal mark; a lone separator is a decimal mark unless the value is a run of exact 3-digit groups (`1,060,200`). +All three share the positional column layout, and the two language-aware ones share `DD-MM-YYYY` dates. They differ in how they read numbers: `frenchDialect` expects space thousands separators and a comma decimal mark, while `englishDialect` accepts either that or the US `1,060.20` form — DEGIRO does **not** switch the body of the file to English number formatting when you switch the interface language, so an English export can carry either. When a value contains both `,` and `.`, the last one is the decimal mark; a lone separator is a decimal mark unless the value is a run of exact 3-digit groups (`1,060,200`). Descriptions are a separate axis: switching the interface language rewrites the header but leaves the description text alone, so an English-header export from a French account still says `Frais DEGIRO de courtage`. The built-in matchers therefore recognise both languages regardless of which dialect read the header. Anything they do not recognise becomes an `unknown` movement — never a dropped row. +### The fallback, for every other language + +DEGIRO's column layout is the same 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 — so the 9th and 11th header cells (0-based indices 8 and 10) are always empty. `genericDialect` recognises that shape and nothing else, reading no header text at all, and interprets dates (`DD-MM-YYYY`, `DD/MM/YYYY`, `YYYY-MM-DD`) and numbers by the same per-value rules as the English dialect. It is registered last, so a French or English export never reaches it. + +Because it is a guess, it says so: `dialect.heuristic` is `true` and a warning lands on `ParseResult.warnings` naming the line it was decided on. Nothing silently depends on it. + +```ts +const result = parseDegiroCsv(dutchCsv); +result.dialect.id; // 'generic' +result.dialect.heuristic; // true +result.warnings[0].message; // 'No dialect recognised this header, so the "generic" fallback ...' +``` + +Two smaller guesses ride along: + +- If the header tokenizes to a single cell, `parseDegiroCsv` retries with `;` and then a tab before giving up. Passing `delimiter` explicitly turns this off. +- `structuralTradeMatcher` and `structuralFxTradeMatcher` recover trades from the _shape_ of a description — ` @ ()` — and take the side from the sign of the mutation, money out being a buy. Both run at a 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 a language the matchers do not know — stay `unknown` with their amounts intact, so balances still reconcile and cash still adds up. Register a matcher for the wording to name them. + ## Domain model ### Movements @@ -274,7 +295,7 @@ parseDegiroCsv(csv, { dialects: [dutchDialect] }); parseDegiroCsv(csv, { dialect: dutchDialect }); ``` -A dialect registered through `dialects` replaces the built-ins; pass `createDefaultDialectRegistry().register(dutchDialect)` to keep them. +A dialect registered through `dialects` replaces the built-ins, including the fallback; pass `createDefaultDialectRegistry().register(dutchDialect, { prepend: true })` to keep them. ### Custom classifier (new movement description) diff --git a/examples/dashboard/src/components/sections/health.tsx b/examples/dashboard/src/components/sections/health.tsx index 5f3bcb2..115fda0 100644 --- a/examples/dashboard/src/components/sections/health.tsx +++ b/examples/dashboard/src/components/sections/health.tsx @@ -3,6 +3,7 @@ import type { BalanceDiscrepancy } from 'libdegiro'; import { CheckCircle2, Copy, Info, TriangleAlert } from 'lucide-react'; import { describeHealthNotes, + plural, describeHealthProblems, diagnosticsText, explainDiscrepancy, @@ -109,9 +110,9 @@ export function HealthSection() { - Parsed {health.rows} rows using the {result.dialect.id} dialect. {health.errors.length}{' '} - errors, {health.warnings.length} warnings, {health.unknown.length} unrecognised - descriptions. + Parsed {plural(health.rows, 'row')} using the {result.dialect.id} dialect.{' '} + {plural(health.errors.length, 'error')}, {plural(health.warnings.length, 'warning')},{' '} + {plural(health.unknown.length, 'unrecognised description')}. {problems.length > 0 ? (