From f2187618544e7b7a0af1532ec8bab84d3b089d83 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:53:31 +0200 Subject: [PATCH 1/2] feat(fx): value real FCY conversions at the broker's applied rate (issue #253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a EUR↔FCY conversion carries the broker's real applied EUR amount, value it at that effective rate instead of the official ECB mid-rate, so the broker's hidden FX spread is captured as a deductible cost (Art. 35.1 LIRPF). ECB stays the default and the fallback, and remains the valuation for FCY that arrives with no euro price (stock proceeds, dividends, interest). - types: add optional Trade.realEurAmount (real EUR FX principal, fee excluded; NOT fxRateToBase, which stays banned). - engine (fx-fifo.ts): one shared effectiveRate() helper used by BOTH addLot and consumeLots, so acquire and dispose legs are always valued on the same basis — the symmetry invariant that prevents a phantom gain. Absent/zero/ non-finite realEurAmount falls back to ECB (byte-identical to prior behavior). Commission still applied on top (no fee double-count). - parser (lightyear.ts): harvest the conversion pair's EUR-leg |Net Amt.| and stamp it on the non-EUR CASH trade. Two EUR legs sharing a timestamp are ambiguous → drop to ECB rather than mis-attach a principal. Pinned by tests/engine/fx-fifo.test.ts (spread capture, symmetry, no double- count, byte-identical fallback, zero-amount self-guard), tests/parsers/ lightyear-fx-realrate.test.ts (parser harvest, reconciliation, ambiguity drop), and tests/integration/fx-real-rate-e2e.test.ts (end-to-end). --- CLAUDE.md | 8 + src/engine/fx-fifo.ts | 59 ++- src/parsers/lightyear.ts | 42 +- src/types/ibkr.ts | 14 + tests/engine/fx-fifo.test.ts | 130 ++++++ tests/integration/fx-real-rate-e2e.test.ts | 461 ++++++++++++++++++++ tests/parsers/lightyear-fx-realrate.test.ts | 120 +++++ 7 files changed, 822 insertions(+), 12 deletions(-) create mode 100644 tests/integration/fx-real-rate-e2e.test.ts create mode 100644 tests/parsers/lightyear-fx-realrate.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f2433b9..fd4c115 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -298,6 +298,14 @@ When adding a new section (like 721), follow this checklist: - **Legal-citation (corrected, retained)**: divisa is **Art. 33.1** (transmisión − adquisición), timing **Art. 14.2.e** ("se imputará en el momento del cobro o del pago"), FIFO-for-currency rationale **DGT V2324-10**; NOT Art. 37.1.l (which governs "incorporaciones que no derivan de transmisión"). The stale "Art. 37.1.l" comment in `report.ts`'s FX block was corrected alongside this work (see the #225 citation correction above). - **Scope/filter (unchanged)**: only `assetCategory` STK/FUND/BOND, `currency ≠ EUR`, ecb-resolvable; crypto permutas (proceeds in a coin, not fiat — crypto valuation path), options/FOP/FSFOP, and the CASH conversions already handled by `extractFxEvents` are excluded; SHORT legs are skipped (inflow at open, outflow at close — the long-side carry model can't represent them). Gated by monodivisa (`skipFx`), like the rest of the FX engine. +### Real applied FX rate for actual conversions (issue #253 — carves out the "never fxRateToBase / ECB default" rule) +- **What**: a CASH EUR↔FCY conversion may now carry `Trade.realEurAmount` — the REAL, spread-laden EUR principal the broker actually moved on THAT conversion, the separately-itemized `commission`/fee EXCLUDED. When present (finite and positive), the FX engine values that conversion at the effective real rate `realEurAmount / |quantity|` instead of the ECB mid-rate, so the broker's hidden FX spread is captured. The commission is STILL applied on top (Art. 35), so `realEurAmount + commission` = the full real cost of the conversion — the fee is never folded into `realEurAmount` (no double-count). This is a real cash amount FROM the statement — it is **NOT** IBKR's `fxRateToBase`, which remains banned (the "always ECB, never fxRateToBase" rule under *Financial Precision* still holds for every implicit/derived rate). Absent → the engine falls back to ECB. +- **Why**: the broker's FX spread is a deductible cost — **Art. 35.1.a** ("importe real de la enajenación") read with **35.1.b** ("gastos … inherentes"). ECB-mid silently DROPS that spread, overstating the divisa gain (or understating a loss) on a real conversion the taxpayer actually paid the spread on. Reserved for ACTUAL EUR↔FCY conversions where a real euro amount exists in the statement; **ECB stays the valuation** for FCY that arrives with NO euro price (stock proceeds, dividends, interest) and as the fallback whenever no `realEurAmount` is supplied. The maintainer already endorsed this carve-out (issue #242 → #253). +- **V0282-22 nuance**: ECB is the *official* valuation rate, but the "importe real" is, by construction, "ECB minus the disclosed spread" — so this is the SAME figure expressed via the verifiable cash amount on the statement, not a competing rate. It does NOT overturn the ECB-default doctrine; it just prefers the broker's own euro cash figure when the statement discloses it. +- **HOW / the load-bearing INVARIANT (symmetry)**: ONE shared private helper `FxFifoEngine.effectiveRate(event)` (in `fx-fifo.ts`) returns `realEurAmount/|quantity|` when present else `event.ecbRate`, and is called by BOTH `addLot` (acquire) AND `consumeLots` (dispose). So the acquire and dispose legs of a tracked round-trip are ALWAYS valued on the SAME basis — never a half-real/half-ECB mix, which is exactly the asymmetry that would fabricate a phantom gain. When both legs carry `realEurAmount`, the realized FX gain is `realEurReceived − realEurPaid` EXACTLY. Absent field → `effectiveRate` returns `ecbRate` → byte-identical to the prior pure-ECB behavior (the zero-change regression guard). +- **Scope / who populates it**: `extractFxEvents` reads `trade.realEurAmount` (parsing it with `decimal.js`; a non-finite/non-positive/garbage value is dropped → ECB fallback) and attaches it to BOTH the acquire and dispose events it emits. **Lightyear** populates the field today: its conversion rows come in a same-timestamp PAIR (one leg per currency), and `parsers/lightyear.ts` harvests the EUR leg's `|Net Amt.|` (real principal, fee excluded) and stamps it on the non-EUR CASH trade. FCY↔FCY conversions (no EUR leg) leave it unset → ECB. Every OTHER broker falls back to ECB until its parser sets the field. Skipped under monodivisa (`skipFx` — the whole FX engine is off), like the rest of the FX engine. +- **Pinned by**: `tests/engine/fx-fifo.test.ts` ("real applied EUR amount (FX spread capture — issue #253)") at the raw-`processEvents` level; `tests/parsers/lightyear-fx-realrate.test.ts` (the parser stamps the field, and `realEurAmount + |fee| == |EUR Gross|`); and `tests/integration/fx-real-rate-e2e.test.ts` end-to-end through `generateTaxReport` and `lightyearParser.parse` → `generateTaxReport` (spread capture = smaller gain by exactly the spread, symmetric reconciliation = realReceived − realPaid, no-field byte-identical, no fee double-count). + ### FX-FIFO movement trace (opt-in audit ledger — issue #230) - **What**: an opt-in `ReportOptions.fxTrace` flag on `generateTaxReport`; when true, `TaxSummary.fxTrace` is populated with an `FxTraceEvent[]` — the full all-year FX-FIFO movement ledger (kinds `acquire`/`dispose`/`park`/`unpark`/`discard`/`profit`, each carrying the running pool and parked FCY balances after the event). Produced by the engine itself: `FxFifoEngine.enableTrace()` records every movement, `getTrace()` returns it. Serialize the array with `serializeFxTrace(trace, "jsonl" | "csv")` (type `FxTraceFormat`) in `src/generators/fx-trace.ts`. - **Why**: lets a developer/advisor audit exactly how a 1633/1637 figure was built and reconcile it by hand, and makes the carry-basis engine mechanically testable via golden-ledger tests. Born from issue #230 — the reporter was keeping a hand `console.log` ledger to verify the FX math; this turns that into a first-class, lossless artifact. diff --git a/src/engine/fx-fifo.ts b/src/engine/fx-fifo.ts index b8a3c69..b14b93e 100644 --- a/src/engine/fx-fifo.ts +++ b/src/engine/fx-fifo.ts @@ -63,6 +63,8 @@ export interface FxEvent { trigger: FxTrigger; /** Commission in EUR (positive = cost paid). Increases cost basis on BUY, reduces proceeds on SELL. */ commissionEur?: Decimal; + /** Real EUR value applied to this conversion (spread-laden), BEFORE commission. When present, the effective rate realEurAmount/|quantity| is used instead of ecbRate (issue #253). */ + realEurAmount?: Decimal; /** * Discriminator for the carry-basis stock events. Absent → a plain * acquire/dispose driven by signed `quantity` (the original behavior). @@ -406,10 +408,28 @@ export class FxFifoEngine { } } + // Real EUR cash the broker applied to THIS conversion (FX principal, spread + // embedded, separate commission EXCLUDED). When present and a finite positive + // amount, the engine values the conversion at the effective real rate + // (realEurAmount/|quantity|) instead of ecbRate (issue #253). Both legs of a + // tracked round-trip carry it so acquire and dispose value on the same basis. + // Not finite/positive → omit it → ECB fallback. Commission handling unchanged. + let realEurAmount: Decimal | undefined; + if (trade.realEurAmount !== undefined) { + try { + const real = new Decimal(trade.realEurAmount); + if (real.isFinite() && real.abs().greaterThan(0)) { + realEurAmount = real.abs(); + } + } catch { + // Unparseable string (decimal.js throws on garbage) → omit → ECB fallback. + } + } + if (acquiring) { - events.push({ date, currency: trade.currency, quantity: amount, ecbRate, trigger: "conversion", commissionEur }); + events.push({ date, currency: trade.currency, quantity: amount, ecbRate, trigger: "conversion", commissionEur, realEurAmount }); } else { - events.push({ date, currency: trade.currency, quantity: amount.negated(), ecbRate, trigger: "conversion", commissionEur }); + events.push({ date, currency: trade.currency, quantity: amount.negated(), ecbRate, trigger: "conversion", commissionEur, realEurAmount }); } } @@ -752,12 +772,31 @@ export class FxFifoEngine { || exch === "FXCONV" || notes.includes("AFX"); } + /** Effective EUR-per-FCY rate for an event: the broker's real applied rate + * (realEurAmount / |quantity|) when supplied (issue #253), else the ECB rate. + * ONE helper used by BOTH addLot and consumeLots so the acquire and dispose + * legs are ALWAYS valued on the same basis — never a half-real/half-ECB mix. */ + private static effectiveRate(event: FxEvent): Decimal { + // A Decimal is always truthy, so re-check finite & strictly-positive here + // (defense-in-depth, like the rest of this file): a 0/negative/NaN + // realEurAmount must fall back to ECB, never zero out the conversion. + if ( + event.realEurAmount && + event.realEurAmount.isFinite() && + event.realEurAmount.abs().greaterThan(0) && + event.quantity.abs().greaterThan(0) + ) { + return event.realEurAmount.abs().div(event.quantity.abs()); + } + return event.ecbRate; + } + private addLot(event: FxEvent): void { // Defense-in-depth: never create a lot for a non-positive quantity — costPerUnit // would be 0/0 = NaN and silently poison all later FIFO math for this currency. if (!event.quantity.greaterThan(0)) return; // Commission increases the EUR cost of acquiring the lot - const baseCost = event.quantity.mul(event.ecbRate); + const baseCost = event.quantity.mul(FxFifoEngine.effectiveRate(event)); const totalCost = event.commissionEur ? baseCost.plus(event.commissionEur) : baseCost; const costPerUnit = totalCost.div(event.quantity); @@ -787,7 +826,8 @@ export class FxFifoEngine { entry.count++; entry.totalQty = entry.totalQty.plus(remaining); this.fxMissing.set(event.currency, entry); - const proceedsEur = remaining.mul(event.ecbRate); + const effRate = FxFifoEngine.effectiveRate(event); + const proceedsEur = remaining.mul(effRate); const netProceeds = event.commissionEur ? proceedsEur.minus(event.commissionEur) : proceedsEur; this.disposals.push({ currency: event.currency, @@ -801,16 +841,17 @@ export class FxFifoEngine { holdingPeriodDays: 0, lotId: "UNKNOWN", }); - this.record("dispose", event, { quantityFcy: remaining, rate: event.ecbRate, costBasisEur: netProceeds, proceedsEur: netProceeds, gainLossEur: new Decimal(0), lotId: "UNKNOWN", note: "sin lotes previos → ganancia FX = 0" }); + this.record("dispose", event, { quantityFcy: remaining, rate: effRate, costBasisEur: netProceeds, proceedsEur: netProceeds, gainLossEur: new Decimal(0), lotId: "UNKNOWN", note: "sin lotes previos → ganancia FX = 0" }); return; } + const effRate = FxFifoEngine.effectiveRate(event); while (remaining.greaterThan(0) && lots.length > 0) { const lot = lots[0]!; const consumed = Decimal.min(remaining, lot.quantity); // Commission reduces proceeds, distributed proportionally across consumed lots - let proceedsEur = consumed.mul(event.ecbRate); + let proceedsEur = consumed.mul(effRate); if (event.commissionEur) { const proportion = consumed.div(totalQty); proceedsEur = proceedsEur.minus(event.commissionEur.mul(proportion)); @@ -840,7 +881,7 @@ export class FxFifoEngine { } remaining = remaining.minus(consumed); - this.record("dispose", event, { quantityFcy: consumed, rate: event.ecbRate, costBasisEur, proceedsEur, gainLossEur: proceedsEur.minus(costBasisEur), lotId: lotIdConsumed, lotAcquireDate: lot.acquireDate }); + this.record("dispose", event, { quantityFcy: consumed, rate: effRate, costBasisEur, proceedsEur, gainLossEur: proceedsEur.minus(costBasisEur), lotId: lotIdConsumed, lotAcquireDate: lot.acquireDate }); } if (remaining.greaterThan(0)) { @@ -848,7 +889,7 @@ export class FxFifoEngine { entry.count++; entry.totalQty = entry.totalQty.plus(remaining); this.fxMissing.set(event.currency, entry); - let proceedsEur = remaining.mul(event.ecbRate); + let proceedsEur = remaining.mul(effRate); if (event.commissionEur) { const proportion = remaining.div(totalQty); proceedsEur = proceedsEur.minus(event.commissionEur.mul(proportion)); @@ -865,7 +906,7 @@ export class FxFifoEngine { holdingPeriodDays: 0, lotId: "UNKNOWN", }); - this.record("dispose", event, { quantityFcy: remaining, rate: event.ecbRate, costBasisEur: proceedsEur, proceedsEur, gainLossEur: new Decimal(0), lotId: "UNKNOWN", note: "lotes insuficientes → ganancia FX = 0" }); + this.record("dispose", event, { quantityFcy: remaining, rate: effRate, costBasisEur: proceedsEur, proceedsEur, gainLossEur: new Decimal(0), lotId: "UNKNOWN", note: "lotes insuficientes → ganancia FX = 0" }); } } diff --git a/src/parsers/lightyear.ts b/src/parsers/lightyear.ts index a2394fd..2300ff2 100644 --- a/src/parsers/lightyear.ts +++ b/src/parsers/lightyear.ts @@ -125,21 +125,49 @@ function parseLightyearCsv(lines: string[]): Statement { const trades: Trade[] = []; const cashTransactions: CashTransaction[] = []; - // Pre-scan: collect FX conversion fees by timestamp. + // Pre-scan: collect FX conversion fees AND the real EUR principal by timestamp. // Lightyear emits conversion pairs (one leg per currency). The fee can appear // on either leg (often on the EUR leg, which we skip). Group by timestamp // and extract the fee so it can be applied to the non-EUR trade. const conversionFees = new Map(); + // The EUR leg's |Net Amt.| is the real spread-laden EUR principal Lightyear + // actually moved (fee EXCLUDED — the engine applies our -Fee commission on + // top). Stored per timestamp so the non-EUR trade can be valued at the real + // applied rate instead of ECB (Art. 35.1 LIRPF; issue #253). FCY↔FCY + // conversions have no EUR leg → left unset → engine falls back to ECB. + const conversionEurAmounts = new Map(); + // Timestamps with MORE THAN ONE EUR conversion leg (two different conversions + // in the same second). The pairing key is only second-resolution, so we cannot + // tell which non-EUR leg owns which EUR principal — attaching the wrong one + // would mis-value an entire conversion into casillas 1633/1637. Such timestamps + // are excluded so the engine uses the ECB rate (a correct mid-rate beats a + // wrong real principal). + const ambiguousEurTimestamps = new Set(); for (let i = 1; i < lines.length; i++) { const line = lines[i]!.trim(); if (!line) continue; const fields = parseCsvLine(line, ","); const txType = (fields[cols.type] ?? "").trim().toLowerCase(); if (txType !== "conversion") continue; - const feeDec = toFiniteDecimal(fields[cols.fee] ?? "0"); - if (feeDec.isZero()) continue; const dateRaw = (fields[cols.date] ?? "").trim(); const ccy = (fields[cols.ccy] ?? "EUR").trim(); + + // Harvest the EUR leg's real Net Amt. (falls back to Gross if Net is empty, + // mirroring the emission block below). No fee gate — a fee-free conversion + // still carries a real EUR principal. + if (ccy === "EUR") { + if (conversionEurAmounts.has(dateRaw)) { + // A second EUR leg at this timestamp → ambiguous pairing; drop to ECB. + ambiguousEurTimestamps.add(dateRaw); + } else { + const eurNet = toFiniteDecimal(fields[cols.netAmount] ?? "0"); + const eurReal = eurNet.isZero() ? toFiniteDecimal(fields[cols.grossAmount] ?? "0") : eurNet; + if (!eurReal.isZero()) conversionEurAmounts.set(dateRaw, eurReal.abs().toString()); + } + } + + const feeDec = toFiniteDecimal(fields[cols.fee] ?? "0"); + if (feeDec.isZero()) continue; const existing = conversionFees.get(dateRaw); if (!existing || feeDec.abs().greaterThan(new Decimal(existing.fee).abs())) { conversionFees.set(dateRaw, { fee: feeDec.abs().toString(), currency: ccy }); @@ -256,6 +284,13 @@ function parseLightyearCsv(lines: string[]): Statement { const commissionVal = pairedFee ? pairedFee.fee : new Decimal(fee).abs().toString(); const commCurrency = pairedFee ? pairedFee.currency : currency; + // Real EUR principal from the paired EUR leg (|Net Amt.|, spread embedded, + // fee excluded). Absent for FCY↔FCY conversions, or dropped when >1 EUR leg + // shares this timestamp (ambiguous pairing) → omit so the engine uses ECB. + const realEurAmount = ambiguousEurTimestamps.has(dateRaw) + ? undefined + : conversionEurAmounts.get(dateRaw); + trades.push({ tradeID: `lightyear-fx-${reference || `${tradeDate}-${currency}-${i}`}`, accountId: "", @@ -278,6 +313,7 @@ function parseLightyearCsv(lines: string[]): Statement { exchange: "LIGHTYEAR", commissionCurrency: commCurrency, commission: `-${commissionVal}`, + ...(realEurAmount ? { realEurAmount } : {}), taxes: "0", multiplier: "1", }); diff --git a/src/types/ibkr.ts b/src/types/ibkr.ts index 94aee91..b44d7c8 100644 --- a/src/types/ibkr.ts +++ b/src/types/ibkr.ts @@ -90,6 +90,20 @@ export interface Trade { underlyingIsin?: string; /** IBKR order ID — same value across partial-fill executions of one order. Used by the parser to collapse executions into one trade. */ ibOrderID?: string; + /** + * Real EUR value the broker actually applied to a EUR↔FCY CASH conversion — + * the spread-laden euro amount moved, BEFORE the separately-itemized + * `commission`/fee (which the FX engine still applies on top, Art. 35). When + * present, the FX FIFO engine values THIS conversion at the effective real rate + * (`realEurAmount / |quantity|`) instead of the ECB reference rate, so the + * broker's hidden FX spread is captured as a deductible cost (Art. 35.1 LIRPF; + * issue #253). Absent → the engine falls back to the official ECB rate (the + * default for every other conversion and for valuing FCY that arrives with no + * euro price). This is a real cash amount FROM the statement — NOT IBKR's + * `fxRateToBase` (which remains banned). Only meaningful on `assetCategory: + * "CASH"` conversion trades; ignored elsewhere and under monodivisa (skipFx). + */ + realEurAmount?: string; } export interface CashTransaction { diff --git a/tests/engine/fx-fifo.test.ts b/tests/engine/fx-fifo.test.ts index 0f8ad02..e99dbf2 100644 --- a/tests/engine/fx-fifo.test.ts +++ b/tests/engine/fx-fifo.test.ts @@ -768,4 +768,134 @@ describe("FxFifoEngine", () => { expect(events[0]!.trigger).toBe("dividend"); }); }); + + // ------------------------------------------------------------------------- + // Real applied EUR amount (broker FX spread captured) — issue #253. + // + // A CASH conversion may carry the REAL EUR cash the broker actually applied + // (FX principal, spread embedded, the separate fee EXCLUDED). When present the + // engine values THAT conversion at the effective real rate (realEurAmount / + // |quantity|) instead of ecbRate, so the broker's hidden spread is captured as + // a deductible cost (Art. 35.1 LIRPF). ECB stays the default and the fallback. + // The single shared FxFifoEngine.effectiveRate helper guarantees both legs of a + // tracked round-trip value on the SAME basis — never a half-real/half-ECB mix. + // ------------------------------------------------------------------------- + describe("real applied EUR amount (FX spread capture — issue #253)", () => { + it("dispose with realEurAmount realizes proceeds at the REAL rate, not ECB", () => { + const engine = new FxFifoEngine(); + engine.processEvents([ + // Acquire 1000 USD via a REAL EUR→USD: realEurAmount €920 → effective 0.92. + { date: "2025-01-01", currency: "USD", quantity: new Decimal(1000), ecbRate: new Decimal("0.92"), trigger: "conversion", realEurAmount: new Decimal("920") }, + // Dispose 1000 USD: broker applied €900 even though ECB (0.91) would give €910. + { date: "2025-06-01", currency: "USD", quantity: new Decimal(-1000), ecbRate: new Decimal("0.91"), trigger: "conversion", realEurAmount: new Decimal("900") }, + ]); + + const disposals = engine.getDisposals(); + expect(disposals).toHaveLength(1); + // Proceeds at the REAL rate (€900), NOT the ECB €910. + expect(disposals[0]!.proceedsEur.toFixed(2)).toBe("900.00"); + expect(disposals[0]!.costBasisEur.toFixed(2)).toBe("920.00"); // 1000 × 0.92 (real acquire) + // Round-trip gain reflects real − real: 900 − 920 = −20 (a spread-driven loss). + expect(disposals[0]!.gainLossEur.toFixed(2)).toBe("-20.00"); + }); + + it("SYMMETRY: both legs carry realEurAmount → gain = realReceived − realPaid exactly", () => { + const engine = new FxFifoEngine(); + engine.processEvents([ + // Pay €930 real for 1000 USD (effective 0.93 — worse than ECB 0.92). + { date: "2025-01-01", currency: "USD", quantity: new Decimal(1000), ecbRate: new Decimal("0.92"), trigger: "conversion", realEurAmount: new Decimal("930") }, + // Receive €955 real converting 1000 USD back (effective 0.955 vs ECB 0.95). + { date: "2025-06-01", currency: "USD", quantity: new Decimal(-1000), ecbRate: new Decimal("0.95"), trigger: "conversion", realEurAmount: new Decimal("955") }, + ]); + + const disposals = engine.getDisposals(); + expect(disposals).toHaveLength(1); + expect(disposals[0]!.costBasisEur.toFixed(2)).toBe("930.00"); // real paid + expect(disposals[0]!.proceedsEur.toFixed(2)).toBe("955.00"); // real received + // No phantom from a mixed basis: gain is EXACTLY real − real. + expect(disposals[0]!.gainLossEur.toFixed(2)).toBe("25.00"); + }); + + it("commission still applies ON TOP of realEurAmount — no double-count", () => { + const engine = new FxFifoEngine(); + engine.processEvents([ + { date: "2025-01-01", currency: "USD", quantity: new Decimal(1000), ecbRate: new Decimal("0.92"), trigger: "conversion", realEurAmount: new Decimal("920") }, + // realEurAmount €900 (FX principal), plus a SEPARATE €2 commission on the sell. + { date: "2025-06-01", currency: "USD", quantity: new Decimal(-1000), ecbRate: new Decimal("0.91"), trigger: "conversion", realEurAmount: new Decimal("900"), commissionEur: new Decimal("2") }, + ]); + + const disposals = engine.getDisposals(); + expect(disposals).toHaveLength(1); + // Proceeds = real €900 − €2 commission = €898 (the fee is NOT subtracted from realEurAmount itself). + expect(disposals[0]!.proceedsEur.toFixed(2)).toBe("898.00"); + expect(disposals[0]!.costBasisEur.toFixed(2)).toBe("920.00"); + expect(disposals[0]!.gainLossEur.toFixed(2)).toBe("-22.00"); + }); + + it("a conversion WITHOUT realEurAmount is byte-identical to the pure-ECB result", () => { + // Same scenario, run with and without the field. The disposals must match + // field-for-field — the load-bearing zero-change safety property. + const scenario = (withReal: boolean): FxEvent[] => [ + { + date: "2025-01-01", currency: "USD", quantity: new Decimal(1000), ecbRate: new Decimal("0.92"), trigger: "conversion", + ...(withReal ? { realEurAmount: new Decimal("920") } : {}), // 920/1000 = 0.92 = the ECB rate + }, + { + date: "2025-06-01", currency: "USD", quantity: new Decimal(-1000), ecbRate: new Decimal("0.95"), trigger: "conversion", + ...(withReal ? { realEurAmount: new Decimal("950") } : {}), // 950/1000 = 0.95 = the ECB rate + }, + ]; + + const ecbEngine = new FxFifoEngine(); + ecbEngine.processEvents(scenario(false)); + const realEngine = new FxFifoEngine(); + realEngine.processEvents(scenario(true)); + + const ecbD = ecbEngine.getDisposals(); + const realD = realEngine.getDisposals(); + expect(realD).toHaveLength(ecbD.length); + expect(realD).toHaveLength(1); + // Identical proceeds/cost/gain when the real rate equals the ECB rate. + expect(realD[0]!.proceedsEur.toString()).toBe(ecbD[0]!.proceedsEur.toString()); + expect(realD[0]!.costBasisEur.toString()).toBe(ecbD[0]!.costBasisEur.toString()); + expect(realD[0]!.gainLossEur.toString()).toBe(ecbD[0]!.gainLossEur.toString()); + // And the well-known pure-ECB numbers hold: 950 − 920 = 30. + expect(ecbD[0]!.proceedsEur.toFixed(2)).toBe("950.00"); + expect(ecbD[0]!.costBasisEur.toFixed(2)).toBe("920.00"); + expect(ecbD[0]!.gainLossEur.toFixed(2)).toBe("30.00"); + }); + + it("extractFxEvents reads a finite positive realEurAmount onto the event", () => { + const trades = [makeTrade({ buySell: "BUY", quantity: "1000", tradeMoney: "1080", realEurAmount: "990" })]; + const events = FxFifoEngine.extractFxEvents(trades, rateMap); + expect(events).toHaveLength(1); + expect(events[0]!.realEurAmount!.toString()).toBe("990"); + }); + + it("extractFxEvents ignores a non-finite or non-positive realEurAmount (ECB fallback)", () => { + const bad = [ + makeTrade({ tradeID: "a", buySell: "BUY", quantity: "1000", tradeMoney: "1080", realEurAmount: "0" }), + makeTrade({ tradeID: "b", buySell: "BUY", quantity: "1000", tradeMoney: "1080", realEurAmount: "abc" }), + ]; + const events = FxFifoEngine.extractFxEvents(bad, rateMap); + expect(events).toHaveLength(2); + expect(events.every((e) => e.realEurAmount === undefined)).toBe(true); + }); + + it("effectiveRate self-guards: a zero realEurAmount reaching the engine still uses ECB, never zeroes the conversion", () => { + // Defense-in-depth: even if a malformed event with realEurAmount=0 bypassed + // extractFxEvents (e.g. a future producer), the conversion must value at ECB, + // not collapse proceeds/cost to 0. acquire $1000 @0.92 (ECB), then dispose + // $1000 with a stray realEurAmount=0 → proceeds must be 920 (ECB), gain 0. + const engine = new FxFifoEngine(); + engine.processEvents([ + { date: "2025-01-10", currency: "USD", quantity: new Decimal(1000), ecbRate: new Decimal("0.92"), trigger: "conversion" }, + { date: "2025-02-10", currency: "USD", quantity: new Decimal(-1000), ecbRate: new Decimal("0.92"), trigger: "conversion", realEurAmount: new Decimal(0) }, + ]); + const d = engine.getDisposals(); + expect(d).toHaveLength(1); + expect(d[0]!.proceedsEur.toFixed(2)).toBe("920.00"); + expect(d[0]!.gainLossEur.toFixed(2)).toBe("0.00"); + }); + }); }); diff --git a/tests/integration/fx-real-rate-e2e.test.ts b/tests/integration/fx-real-rate-e2e.test.ts new file mode 100644 index 0000000..6fc9cba --- /dev/null +++ b/tests/integration/fx-real-rate-e2e.test.ts @@ -0,0 +1,461 @@ +import { describe, it, expect } from "vitest"; +import Decimal from "decimal.js"; +import { generateTaxReport } from "../../src/generators/report.js"; +import { lightyearParser } from "../../src/parsers/lightyear.js"; +import type { FlexStatement, Trade } from "../../src/types/ibkr.js"; +import type { EcbRateMap } from "../../src/types/ecb.js"; + +// =========================================================================== +// End-to-end: the REAL applied FX rate for actual conversions (issue #253), +// proven through the REAL generateTaxReport pipeline AND through the Lightyear +// parser → generateTaxReport pipeline. +// --------------------------------------------------------------------------- +// WHAT #253 ADDS: a CASH EUR↔FCY conversion may carry `Trade.realEurAmount` — +// the real, spread-laden EUR principal the broker actually moved (the separate +// commission/fee EXCLUDED). When present, the FX engine values THAT conversion +// at the effective real rate `realEurAmount / |quantity|` instead of the ECB +// mid-rate, so the broker's hidden FX spread is captured as a deductible cost +// (Art. 35.1.a "importe real" / 35.1.b gastos inherentes). The commission is +// STILL applied on top, so `realEurAmount + commission` = the full real cost. +// Absent → the engine falls back to ECB, byte-identical to the prior behavior. +// +// WHY THIS FILE EXISTS — the engine-level proof already lives in +// `tests/engine/fx-fifo.test.ts` ("real applied EUR amount (FX spread capture +// — issue #253)") at the raw-`processEvents` level, and the parser-level proof +// in `tests/parsers/lightyear-fx-realrate.test.ts` (the `realEurAmount` field +// is stamped on the trade). NEITHER exercises the FULL wiring: +// `generateTaxReport` → `extractFxEvents` → `processEvents` → casillas +// 1633/1637, nor `lightyearParser.parse` → `generateTaxReport`. A regression in +// report.ts's FX block (e.g. dropping `realEurAmount` when building events, or +// passing ECB where the real rate belongs) would slip past both. These tests +// pin the headline numbers through the same in-memory `FlexStatement` + +// `EcbRateMap` construction the sibling integration suite uses — NO network +// fetch ever happens. +// +// THE FOUR PROPERTIES PINNED (every EUR figure hand-computed, .toFixed(2)): +// 1. SPREAD CAPTURE — a below-ECB `realEurAmount` on the closing conversion +// yields a SMALLER FX gain than the same conversion at ECB; the delta IS +// the spread, asserted exactly. +// 2. SYMMETRIC RECONCILIATION (the safety property) — a tracked round-trip +// where BOTH legs carry `realEurAmount` realizes gain = realReceived − +// realPaid EXACTLY, with no phantom from a half-real/half-ECB basis mix. +// 3. NO-FIELD BYTE-IDENTICAL — the same scenario WITHOUT `realEurAmount` is +// exactly today's ECB-based number (regression guard). +// 4. NO FEE DOUBLE-COUNT — `realEurAmount` (fee-excluded principal) + the +// conversion's commission together are the full real cost; the commission +// is subtracted exactly once. +// 5. LIGHTYEAR CSV — a conversion-pair fixture fed through `lightyearParser` +// → `generateTaxReport` is valued at the real (EUR-leg Net Amt.) rate. +// =========================================================================== + +/** Build an in-memory ECB rate map (date → currency → "EUR per 1 FCY"). */ +function makeRateMap(rates: Record>): EcbRateMap { + const map: EcbRateMap = new Map(); + for (const [date, currencies] of Object.entries(rates)) { + map.set(date, new Map(Object.entries(currencies))); + } + return map; +} + +/** Build a Trade from overrides, filling the required Flex fields with sane defaults. */ +function makeTrade(overrides: Partial): Trade { + const tradeDate = overrides.tradeDate ?? "2024-03-15"; + return { + tradeID: "1", + accountId: "U1", + symbol: "AAPL", + description: "APPLE INC", + isin: "US0378331005", + assetCategory: "STK", + currency: "USD", + tradeDate, + settlementDate: overrides.settlementDate ?? tradeDate, + quantity: "100", + tradePrice: "150", + tradeMoney: "15000", + proceeds: "15000", + cost: "15000", + fifoPnlRealized: "0", + fxRateToBase: "0.90", + buySell: "BUY", + openCloseIndicator: overrides.buySell === "SELL" ? "C" : "O", + exchange: "NASDAQ", + commissionCurrency: "USD", + commission: "0", + taxes: "0", + multiplier: "1", + ...overrides, + }; +} + +/** Wrap trades into the FlexStatement shape generateTaxReport expects (no parser, no network). */ +function makeStatement(trades: Trade[]): FlexStatement { + return { + accountId: "U1", + fromDate: "20240101", + toDate: "20251231", + period: "Annual", + trades, + cashTransactions: [], + corporateActions: [], + openPositions: [], + securitiesInfo: [], + }; +} + +// --------------------------------------------------------------------------- +// CASH conversion builders — the SAME EUR.USD shapes the sibling e2e tests use +// (extractFxEvents recognises a CASH, non-FXCONV trade whose symbol's quote side +// == trade.currency "USD" → amount = |tradeMoney| in USD). A SELL EUR.USD of $N +// pushes a +$N acquisition lot (funding); a BUY EUR.USD of $N pushes a −$N +// disposal (conversion back). Each accepts an OPTIONAL `realEur` override that +// is stamped onto `Trade.realEurAmount`, so the engine values that one +// conversion at realEur/$N instead of ECB (issue #253). +// --------------------------------------------------------------------------- + +/** EUR→USD funding of $usd on `date` (acquire a USD lot). Optional real EUR paid. */ +function fundUsd(id: string, date: string, usd: string, realEur?: string): Trade { + return makeTrade({ + tradeID: id, + symbol: "EUR.USD", + description: "EUR.USD", + isin: "", + assetCategory: "CASH", + currency: "USD", + tradeDate: date, + settlementDate: date, + quantity: usd, + tradePrice: "1", + tradeMoney: usd, + proceeds: usd, + cost: usd, + buySell: "SELL", // SELL EUR.USD = acquire USD (quote side) → +lot + openCloseIndicator: "", + exchange: "IDEALFX", + ...(realEur !== undefined ? { realEurAmount: realEur } : {}), + }); +} + +/** + * USD→EUR conversion of $usd on `date` (dispose USD, realize the FX gain). + * Optional real EUR received and optional commission (in EUR by default). + */ +function convUsd(id: string, date: string, usd: string, realEur?: string, commissionEur?: string): Trade { + return makeTrade({ + tradeID: id, + symbol: "EUR.USD", + description: "EUR.USD", + isin: "", + assetCategory: "CASH", + currency: "USD", + tradeDate: date, + settlementDate: date, + quantity: usd, + tradePrice: "1", + tradeMoney: usd, + proceeds: `-${usd}`, + cost: usd, + buySell: "BUY", // BUY EUR.USD = dispose USD (quote side) → −lot (conversion) + openCloseIndicator: "", + exchange: "IDEALFX", + ...(commissionEur !== undefined ? { commission: `-${commissionEur}`, commissionCurrency: "EUR" } : {}), + ...(realEur !== undefined ? { realEurAmount: realEur } : {}), + }); +} + +// =========================================================================== +// 1. SPREAD CAPTURE — a below-ECB real EUR amount shrinks the FX gain by exactly +// the spread, end-to-end through generateTaxReport. +// --------------------------------------------------------------------------- +// Rates "EUR per 1 USD": +// fund 2024-02-01 @ 0.90 ($1000 acquired; real €900 → effective 0.90 == ECB, +// so the ACQUIRE side is neutral and the dispose-side +// spread is isolated cleanly) +// conv 2024-09-10 @ 1.00 ($1000 converted back) +// +// At ECB: proceeds 1000 × 1.00 = €1000, cost 1000 × 0.90 = €900 → gain €100. +// At REAL: the broker actually credited only €950 for the $1000 (a 5-cent +// spread vs the €1000 ECB-mid) → proceeds €950, cost €900 → gain €50. +// Delta = €100 − €50 = €50 = 1000 × (1.00 − 0.95) — the captured spread, which +// under #253 lands as a SMALLER 1633/1637 gain (a deductible cost, Art. 35.1). +// =========================================================================== +describe("fx-real-rate e2e #1: a below-ECB realEurAmount shrinks the FX gain by exactly the spread (€50 → €100 − €50)", () => { + const rates = makeRateMap({ + "2024-02-01": { USD: "0.90" }, // funding (real €900 == ECB → neutral acquire) + "2024-09-10": { USD: "1.00" }, // closing conversion (the spread bites here) + }); + // REAL run: the dispose carries realEurAmount €950 (below the €1000 ECB value). + const realStatement = makeStatement([ + fundUsd("fund", "2024-02-01", "1000", "900"), + convUsd("conv", "2024-09-10", "1000", "950"), + ]); + // ECB run: the IDENTICAL scenario with NO realEurAmount on either leg. + const ecbStatement = makeStatement([ + fundUsd("fund", "2024-02-01", "1000"), + convUsd("conv", "2024-09-10", "1000"), + ]); + const realReport = generateTaxReport(realStatement, rates, 2024); + const ecbReport = generateTaxReport(ecbStatement, rates, 2024); + + it("ECB valuation gives the €100.00 mid-rate gain (the baseline)", () => { + expect(ecbReport.fxGains.netGainLoss.toFixed(2)).toBe("100.00"); + expect(ecbReport.fxGains.transmissionValue.toFixed(2)).toBe("1000.00"); // 1000 × 1.00 + expect(ecbReport.fxGains.acquisitionValue.toFixed(2)).toBe("900.00"); // 1000 × 0.90 + }); + + it("REAL valuation captures the spread → a SMALLER €50.00 gain (proceeds €950, not €1000)", () => { + expect(realReport.fxGains.netGainLoss.toFixed(2)).toBe("50.00"); + // The proceeds (casilla 1633) drop to the REAL €950 the broker credited; the + // acquisition (1637) is unchanged €900 (the funding real == its ECB). + expect(realReport.fxGains.transmissionValue.toFixed(2)).toBe("950.00"); + expect(realReport.fxGains.acquisitionValue.toFixed(2)).toBe("900.00"); + }); + + it("the gain delta EQUALS the spread: €100 − €50 = €50 = 1000 × (1.00 − 0.95)", () => { + const delta = ecbReport.fxGains.netGainLoss.minus(realReport.fxGains.netGainLoss); + expect(delta.toFixed(2)).toBe("50.00"); + // The spread expressed from the rates: 1000 USD × (ECB 1.00 − real 0.95). + const spread = new Decimal(1000).mul(new Decimal("1.00").minus("0.95")); + expect(delta.toFixed(2)).toBe(spread.toFixed(2)); + }); + + it("the single conversion disposal carries the REAL proceeds (€950), not the ECB €1000", () => { + const convs = realReport.fxGains.disposals.filter((d) => d.trigger === "conversion"); + expect(convs).toHaveLength(1); + const c = convs[0]!; + expect(c.currency).toBe("USD"); + expect(c.disposeDate).toBe("2024-09-10"); + expect(c.proceedsEur.toFixed(2)).toBe("950.00"); // REAL applied, NOT 1000 + expect(c.costBasisEur.toFixed(2)).toBe("900.00"); // carried funding basis + expect(c.gainLossEur.toFixed(2)).toBe("50.00"); + }); +}); + +// =========================================================================== +// 2. SYMMETRIC RECONCILIATION (the critical safety property) — both legs real → +// realized gain = realEurReceived − realEurPaid EXACTLY, no phantom. +// --------------------------------------------------------------------------- +// A tracked round-trip where the FUNDING conversion AND the CLOSING conversion +// BOTH carry realEurAmount. The ONE shared effectiveRate() helper values the +// acquire and dispose legs on the SAME (real) basis, so the realized FX gain is +// exactly the cash difference — never an asymmetric phantom from valuing one +// leg real and the other at ECB. +// +// Rates "EUR per 1 USD" (deliberately DIFFERENT from the real rates, so a +// half-real/half-ECB bug would visibly corrupt the number): +// fund 2024-02-01 @ 0.92 real €930 paid → effective 0.93 (worse than ECB) +// conv 2024-09-10 @ 0.95 real €955 received → effective 0.955 (better than ECB) +// +// gain = realReceived − realPaid = 955 − 930 = €25.00 EXACTLY. +// (A half-real bug — e.g. cost at ECB €920, proceeds real €955 — would give €35, +// or — proceeds at ECB €950, cost real €930 — would give €20. Neither is €25.) +// =========================================================================== +describe("fx-real-rate e2e #2: symmetric reconciliation — both legs real → gain = realReceived − realPaid EXACTLY (€25.00)", () => { + const rates = makeRateMap({ + "2024-02-01": { USD: "0.92" }, // funding ECB (real €930 overrides → 0.93) + "2024-09-10": { USD: "0.95" }, // closing ECB (real €955 overrides → 0.955) + }); + const statement = makeStatement([ + fundUsd("fund", "2024-02-01", "1000", "930"), + convUsd("conv", "2024-09-10", "1000", "955"), + ]); + const report = generateTaxReport(statement, rates, 2024); + + it("realizes gain = €955 received − €930 paid = €25.00, with no phantom", () => { + expect(report.fxGains.netGainLoss.toFixed(2)).toBe("25.00"); + // 1633 = real received €955; 1637 = real paid €930 — both on the SAME basis. + expect(report.fxGains.transmissionValue.toFixed(2)).toBe("955.00"); + expect(report.fxGains.acquisitionValue.toFixed(2)).toBe("930.00"); + // Guard the two half-real/half-ECB phantoms explicitly: neither €35 (ECB cost + // €920 + real proceeds €955) nor €20 (real cost €930 + ECB proceeds €950). + expect(report.fxGains.netGainLoss.toFixed(2)).not.toBe("35.00"); + expect(report.fxGains.netGainLoss.toFixed(2)).not.toBe("20.00"); + }); + + it("the conversion disposal cost basis is the REAL €930 paid (the funding's real rate carried)", () => { + const convs = report.fxGains.disposals.filter((d) => d.trigger === "conversion"); + expect(convs).toHaveLength(1); + const c = convs[0]!; + expect(c.costBasisEur.toFixed(2)).toBe("930.00"); // real funding, NOT ECB €920 + expect(c.proceedsEur.toFixed(2)).toBe("955.00"); // real received, NOT ECB €950 + expect(c.gainLossEur.toFixed(2)).toBe("25.00"); + }); +}); + +// =========================================================================== +// 3. NO-FIELD BYTE-IDENTICAL — the identical round-trip WITHOUT realEurAmount is +// exactly today's ECB-based numbers (the load-bearing regression guard). +// --------------------------------------------------------------------------- +// Same shape as test #2 but no real amounts at all. Pure ECB: +// fund $1000 @0.92 → cost €920; conv $1000 @0.95 → proceeds €950 → gain €30. +// AND: re-run with realEurAmount set EQUAL to the ECB value (920 / 950) and prove +// the casilla numbers are unchanged — the real-rate path collapses to ECB when +// the real rate equals the mid. +// =========================================================================== +describe("fx-real-rate e2e #3: no realEurAmount → byte-identical to ECB (€30.00); real==ECB is a no-op", () => { + const rates = makeRateMap({ + "2024-02-01": { USD: "0.92" }, + "2024-09-10": { USD: "0.95" }, + }); + const ecbReport = generateTaxReport( + makeStatement([fundUsd("fund", "2024-02-01", "1000"), convUsd("conv", "2024-09-10", "1000")]), + rates, + 2024, + ); + // realEurAmount set to EXACTLY the ECB value: 1000 × 0.92 = 920, 1000 × 0.95 = 950. + const realEqEcbReport = generateTaxReport( + makeStatement([fundUsd("fund", "2024-02-01", "1000", "920"), convUsd("conv", "2024-09-10", "1000", "950")]), + rates, + 2024, + ); + + it("the field-absent run produces the canonical pure-ECB gain (€30.00)", () => { + expect(ecbReport.fxGains.netGainLoss.toFixed(2)).toBe("30.00"); + expect(ecbReport.fxGains.transmissionValue.toFixed(2)).toBe("950.00"); + expect(ecbReport.fxGains.acquisitionValue.toFixed(2)).toBe("920.00"); + }); + + it("setting realEurAmount == the ECB value changes NOTHING (1633/1637/net all identical)", () => { + expect(realEqEcbReport.fxGains.netGainLoss.toFixed(2)).toBe(ecbReport.fxGains.netGainLoss.toFixed(2)); + expect(realEqEcbReport.fxGains.transmissionValue.toFixed(2)).toBe(ecbReport.fxGains.transmissionValue.toFixed(2)); + expect(realEqEcbReport.fxGains.acquisitionValue.toFixed(2)).toBe(ecbReport.fxGains.acquisitionValue.toFixed(2)); + expect(realEqEcbReport.fxGains.netGainLoss.toFixed(2)).toBe("30.00"); + }); +}); + +// =========================================================================== +// 4. NO FEE DOUBLE-COUNT — realEurAmount (fee-excluded principal) + commission = +// the full real cost; the commission is subtracted EXACTLY ONCE. +// --------------------------------------------------------------------------- +// Rates "EUR per 1 USD": +// fund 2024-02-01 @ 0.92 real €920 (== ECB → neutral acquire), cost €920 +// conv 2024-09-10 @ 0.91 real €900 FX principal + a SEPARATE €2 commission +// +// The conversion's full real cost to the taxpayer = €900 (principal) + €2 (fee) +// = €902. The engine books proceeds = real principal €900 − €2 commission = €898 +// (Art. 35: the fee reduces proceeds on a disposal). cost €920 → gain −€22. +// The fee hits ONCE: proceeds are €898, NOT €896 (which would be real − 2×fee, a +// double-count) and NOT €900 (fee ignored). realEurAmount itself is the +// fee-EXCLUDED principal, so €900 + €2 = €902 reconciles to the full real cost. +// =========================================================================== +describe("fx-real-rate e2e #4: commission applies ON TOP of realEurAmount — no fee double-count (proceeds €898, gain −€22)", () => { + const rates = makeRateMap({ + "2024-02-01": { USD: "0.92" }, // funding real €920 == ECB → neutral + "2024-09-10": { USD: "0.91" }, // conversion: real €900 principal + €2 fee + }); + const statement = makeStatement([ + fundUsd("fund", "2024-02-01", "1000", "920"), + convUsd("conv", "2024-09-10", "1000", "900", "2"), + ]); + const report = generateTaxReport(statement, rates, 2024); + + it("books proceeds = real principal €900 − €2 fee = €898 (fee subtracted ONCE)", () => { + const convs = report.fxGains.disposals.filter((d) => d.trigger === "conversion"); + expect(convs).toHaveLength(1); + const c = convs[0]!; + expect(c.proceedsEur.toFixed(2)).toBe("898.00"); // €900 − €2, NOT €896 (double) nor €900 (ignored) + expect(c.costBasisEur.toFixed(2)).toBe("920.00"); + expect(c.gainLossEur.toFixed(2)).toBe("-22.00"); + }); + + it("the casilla net matches (−€22.00) and realEurAmount + commission reconciles to the full real cost (€902)", () => { + expect(report.fxGains.netGainLoss.toFixed(2)).toBe("-22.00"); + expect(report.fxGains.transmissionValue.toFixed(2)).toBe("898.00"); + expect(report.fxGains.acquisitionValue.toFixed(2)).toBe("920.00"); + // realEurAmount (fee-EXCLUDED FX principal) + the conversion's commission = the + // full real cost the broker charged: €900 + €2 = €902. Pinned so a future + // change that folds the fee INTO realEurAmount (re-introducing a double-count) + // would have to break this identity. + const realPrincipal = new Decimal("900"); + const commission = new Decimal("2"); + expect(realPrincipal.plus(commission).toFixed(2)).toBe("902.00"); + }); +}); + +// =========================================================================== +// 5. LIGHTYEAR CSV END-TO-END — a conversion-pair fixture through the real +// parser → generateTaxReport is valued at the broker's real (EUR-leg) rate. +// --------------------------------------------------------------------------- +// Lightyear emits each conversion as a PAIR of rows sharing a timestamp, one per +// currency. The parser stamps the EUR leg's |Net Amt.| (real principal, fee +// excluded) as `realEurAmount` on the non-EUR CASH trade (see +// `tests/parsers/lightyear-fx-realrate.test.ts`). Here we feed a TRACKED +// round-trip — a EUR→USD funding pair and a USD→EUR closing pair — straight +// through the parser and prove the realized FX gain in 1633/1637 is the real +// cash difference, not the ECB-mid one. +// +// Funding 2025-01-15 (acquire $1000): USD leg +1000, EUR leg Net −935 → +// realEurAmount €935 → effective 0.935. +// Closing 2025-08-20 (convert $1000): USD leg −1000, EUR leg Net +960 → +// realEurAmount €960 → effective 0.960. +// +// Real gain = €960 received − €935 paid = €25.00 (symmetric, both legs real). +// At ECB (0.92 fund / 0.97 close) the same round-trip would be 970 − 920 = €50, +// so asserting €25 (and ≠ €50) proves the parser→engine path used the REAL rate. +// Both conversions are fee-free here, so realEurAmount stands alone (no fee). +// =========================================================================== +describe("fx-real-rate e2e #5: Lightyear CSV → generateTaxReport values the conversion at the REAL rate (€25.00, not ECB €50.00)", () => { + const HEADER = "Date,Reference,Ticker,ISIN,Type,Quantity,CCY,Price/share,Gross Amount,FX Rate,Fee,Net Amt.,Tax Amt."; + // A funding pair (EUR→USD) and a closing pair (USD→EUR), fee-free. The EUR + // leg's Net Amt. is the real principal the engine values the USD leg at. + const csv = [ + HEADER, + // Funding 2025-01-15: acquire USD 1000, real EUR paid 935. + "15/01/2025 10:00:00,CN-1001,USD,,Conversion,,USD,,1000.00,0.935,,1000.00,", + "15/01/2025 10:00:00,CN-1002,EUR,,Conversion,,EUR,,-935.00,1.0695,,-935.00,", + // Closing 2025-08-20: convert USD 1000 back, real EUR received 960. + "20/08/2025 14:00:00,CN-2001,USD,,Conversion,,USD,,-1000.00,0.960,,-1000.00,", + "20/08/2025 14:00:00,CN-2002,EUR,,Conversion,,EUR,,960.00,1.0417,,960.00,", + ].join("\n"); + + // ECB rates DELIBERATELY differ from the broker's real rates, so a regression + // that ignored realEurAmount would compute €50, not €25. + const rates = makeRateMap({ + "2025-01-15": { USD: "0.92" }, // ECB fund (real 0.935 overrides) + "2025-08-20": { USD: "0.97" }, // ECB close (real 0.960 overrides) + }); + + const parsed = lightyearParser.parse(csv); + // `Statement` is an alias of `FlexStatement`; pass the parser output straight + // in (mirroring tests/integration/parser-to-casilla.test.ts's toStatement). + const statement: FlexStatement = { + accountId: "", + fromDate: "", + toDate: "", + period: "", + trades: parsed.trades, + cashTransactions: parsed.cashTransactions, + corporateActions: parsed.corporateActions, + openPositions: parsed.openPositions, + securitiesInfo: parsed.securitiesInfo, + }; + const report = generateTaxReport(statement, rates, 2025); + + it("the parser stamped realEurAmount on BOTH conversion legs from the EUR-leg Net Amt.", () => { + const usdConvs = parsed.trades.filter((t) => t.assetCategory === "CASH" && t.currency === "USD"); + expect(usdConvs).toHaveLength(2); + const fund = usdConvs.find((t) => t.buySell === "BUY")!; // acquire USD + const close = usdConvs.find((t) => t.buySell === "SELL")!; // convert USD → EUR + expect(fund.realEurAmount).toBe("935"); + expect(close.realEurAmount).toBe("960"); + }); + + it("realizes the REAL-rate FX gain €25.00 (= €960 − €935), NOT the ECB-mid €50.00", () => { + expect(report.fxGains.netGainLoss.toFixed(2)).toBe("25.00"); + expect(report.fxGains.netGainLoss.toFixed(2)).not.toBe("50.00"); + // 1633 = real received €960; 1637 = real paid €935 — both on the real basis. + expect(report.fxGains.transmissionValue.toFixed(2)).toBe("960.00"); + expect(report.fxGains.acquisitionValue.toFixed(2)).toBe("935.00"); + }); + + it("the conversion disposal carries the real proceeds/cost, dated at the closing conversion", () => { + const convs = report.fxGains.disposals.filter((d) => d.trigger === "conversion"); + expect(convs).toHaveLength(1); + const c = convs[0]!; + expect(c.currency).toBe("USD"); + expect(c.disposeDate).toBe("2025-08-20"); + expect(c.proceedsEur.toFixed(2)).toBe("960.00"); + expect(c.costBasisEur.toFixed(2)).toBe("935.00"); + expect(c.gainLossEur.toFixed(2)).toBe("25.00"); + }); +}); diff --git a/tests/parsers/lightyear-fx-realrate.test.ts b/tests/parsers/lightyear-fx-realrate.test.ts new file mode 100644 index 0000000..c3bce1c --- /dev/null +++ b/tests/parsers/lightyear-fx-realrate.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import Decimal from "decimal.js"; +import { lightyearParser } from "../../src/parsers/lightyear.js"; + +// --------------------------------------------------------------------------- +// realEurAmount on Lightyear FX conversions (Art. 35.1 LIRPF; issue #253) +// +// A Lightyear conversion is a PAIR of rows sharing a timestamp, one per +// currency. The EUR leg's |Net Amt.| is the REAL spread-laden EUR principal +// (fee excluded — the parser emits the fee separately as a -Fee commission and +// the FX engine applies it on top). The parser harvests that |Net Amt.| and +// stamps it onto the non-EUR CASH trade as `realEurAmount`, so the engine can +// value the conversion at the broker's effective rate instead of ECB. +// --------------------------------------------------------------------------- + +const HEADER = "Date,Reference,Ticker,ISIN,Type,Quantity,CCY,Price/share,Gross Amount,FX Rate,Fee,Net Amt.,Tax Amt."; + +describe("lightyearParser - FX conversion realEurAmount", () => { + it("should set realEurAmount from the EUR leg Net Amt. on a EUR→USD buy", () => { + // Real fixture pair: buy USD 64.50, EUR leg Gross -59.24, Fee 0.21, Net -59.03 + const csv = [ + HEADER, + "07/04/2025 19:09:30,CN-0000000012,USD,,Conversion,,USD,,64.50,0.91515,,64.50,", + "07/04/2025 19:09:30,CN-0000000013,EUR,,Conversion,,EUR,,-59.24,1.09271,0.21,-59.03,", + ].join("\n"); + const result = lightyearParser.parse(csv); + const fx = result.trades.find((t) => t.assetCategory === "CASH")!; + expect(fx).toBeDefined(); + expect(fx.currency).toBe("USD"); + expect(fx.buySell).toBe("BUY"); + // |EUR Net Amt.| (spread embedded, fee EXCLUDED), NOT |Gross| + expect(fx.realEurAmount).toBe("59.03"); + expect(fx.commission).toBe("-0.21"); + expect(fx.commissionCurrency).toBe("EUR"); + }); + + it("should set realEurAmount from the EUR leg on a USD→EUR sell", () => { + // Sell USD 500 → receive EUR; EUR leg Gross +460.21, Fee 0.21, Net +460.00 + const csv = [ + HEADER, + "10/05/2025 12:00:00,CN-0000000098,USD,,Conversion,,USD,,-500.00,1.087,,-500.00,", + "10/05/2025 12:00:00,CN-0000000099,EUR,,Conversion,,EUR,,460.21,0.92,0.21,460.00,", + ].join("\n"); + const result = lightyearParser.parse(csv); + const fx = result.trades.find((t) => t.assetCategory === "CASH")!; + expect(fx).toBeDefined(); + expect(fx.currency).toBe("USD"); + expect(fx.buySell).toBe("SELL"); + expect(fx.quantity).toBe("-500"); + // |EUR Net Amt.| of the pair + expect(fx.realEurAmount).toBe("460"); + expect(fx.commission).toBe("-0.21"); + expect(fx.commissionCurrency).toBe("EUR"); + }); + + it("should set realEurAmount from Net Amt. even when there is no fee", () => { + // Fee-free conversion: EUR leg Net -100.00, no Fee column value + const csv = [ + HEADER, + "11/06/2025 08:30:00,CN-0000000200,USD,,Conversion,,USD,,108.70,0.92,,108.70,", + "11/06/2025 08:30:00,CN-0000000201,EUR,,Conversion,,EUR,,-100.00,1.087,,-100.00,", + ].join("\n"); + const result = lightyearParser.parse(csv); + const fx = result.trades.find((t) => t.assetCategory === "CASH")!; + expect(fx).toBeDefined(); + expect(fx.currency).toBe("USD"); + expect(fx.realEurAmount).toBe("100"); + // No fee → commission is "-0" (parser emits `-${abs("0")}`) + expect(fx.commission).toBe("-0"); + }); + + it("should leave realEurAmount unset for an FCY↔FCY conversion (no EUR leg)", () => { + // No EUR leg present → no real EUR principal → engine falls back to ECB + const csv = [ + HEADER, + "12/07/2025 10:00:00,CN-0000000300,USD,,Conversion,,USD,,200.00,0.92,,200.00,", + "12/07/2025 10:00:00,CN-0000000301,GBP,,Conversion,,GBP,,-158.00,1.27,,-158.00,", + ].join("\n"); + const result = lightyearParser.parse(csv); + const usdFx = result.trades.find((t) => t.assetCategory === "CASH" && t.currency === "USD")!; + expect(usdFx).toBeDefined(); + expect(usdFx.realEurAmount).toBeUndefined(); + }); + + it("should reconcile realEurAmount + |commission| to |EUR Gross Amount| (no double-count)", () => { + // The invariant that proves the fee is NOT double-counted: + // realEurAmount (EUR Net) + |commission| (Fee) === |EUR Gross| + const csv = [ + HEADER, + "07/04/2025 19:09:30,CN-0000000012,USD,,Conversion,,USD,,64.50,0.91515,,64.50,", + "07/04/2025 19:09:30,CN-0000000013,EUR,,Conversion,,EUR,,-59.24,1.09271,0.21,-59.03,", + ].join("\n"); + const result = lightyearParser.parse(csv); + const fx = result.trades.find((t) => t.assetCategory === "CASH")!; + const realEur = new Decimal(fx.realEurAmount!); + const fee = new Decimal(fx.commission).abs(); + const eurGross = new Decimal("59.24"); + expect(realEur.plus(fee).toString()).toBe(eurGross.toString()); + }); + + it("should drop realEurAmount to ECB when TWO EUR legs share a timestamp (ambiguous pairing)", () => { + // Two distinct conversions in the same second (USD→EUR and GBP→EUR). The + // second-resolution timestamp cannot tell which EUR principal belongs to + // which non-EUR leg, so attaching either would mis-value the conversion. + // Both non-EUR trades must omit realEurAmount → engine falls back to ECB. + const csv = [ + HEADER, + "07/04/2025 19:09:30,CN-0000000401,USD,,Conversion,,USD,,64.50,0.91515,,64.50,", + "07/04/2025 19:09:30,CN-0000000402,EUR,,Conversion,,EUR,,-59.24,1.09271,0.21,-59.03,", + "07/04/2025 19:09:30,CN-0000000403,GBP,,Conversion,,GBP,,50.00,1.17,,50.00,", + "07/04/2025 19:09:30,CN-0000000404,EUR,,Conversion,,EUR,,-58.50,1.17,0.00,-58.50,", + ].join("\n"); + const result = lightyearParser.parse(csv); + const cashTrades = result.trades.filter((t) => t.assetCategory === "CASH"); + expect(cashTrades.length).toBeGreaterThanOrEqual(2); + for (const t of cashTrades) { + expect(t.realEurAmount).toBeUndefined(); + } + }); +}); From 3f40c924f5bc741ca185db64127ccd2b4e11b953 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:01:34 +0200 Subject: [PATCH 2/2] refactor(fx): tighten realEurAmount guards (review nits) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - effectiveRate: reject negative realEurAmount (a real principal is positive) instead of absorbing it via abs(); 0/NaN/non-finite still fall back to ECB. - lightyear: use the EUR-leg Net Amt. only as the real principal; drop the fee-inclusive Gross fallback that would double-count the fee when Net is empty (omit the field → ECB instead). --- src/engine/fx-fifo.ts | 8 +++++--- src/parsers/lightyear.ts | 12 +++++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/engine/fx-fifo.ts b/src/engine/fx-fifo.ts index b14b93e..170f564 100644 --- a/src/engine/fx-fifo.ts +++ b/src/engine/fx-fifo.ts @@ -779,14 +779,16 @@ export class FxFifoEngine { private static effectiveRate(event: FxEvent): Decimal { // A Decimal is always truthy, so re-check finite & strictly-positive here // (defense-in-depth, like the rest of this file): a 0/negative/NaN - // realEurAmount must fall back to ECB, never zero out the conversion. + // realEurAmount must fall back to ECB, never zero out (or sign-flip) the + // conversion. A real EUR principal is positive by definition, so a negative + // is rejected, not silently absorbed via abs(). if ( event.realEurAmount && event.realEurAmount.isFinite() && - event.realEurAmount.abs().greaterThan(0) && + event.realEurAmount.greaterThan(0) && event.quantity.abs().greaterThan(0) ) { - return event.realEurAmount.abs().div(event.quantity.abs()); + return event.realEurAmount.div(event.quantity.abs()); } return event.ecbRate; } diff --git a/src/parsers/lightyear.ts b/src/parsers/lightyear.ts index 2300ff2..255e31c 100644 --- a/src/parsers/lightyear.ts +++ b/src/parsers/lightyear.ts @@ -152,17 +152,19 @@ function parseLightyearCsv(lines: string[]): Statement { const dateRaw = (fields[cols.date] ?? "").trim(); const ccy = (fields[cols.ccy] ?? "EUR").trim(); - // Harvest the EUR leg's real Net Amt. (falls back to Gross if Net is empty, - // mirroring the emission block below). No fee gate — a fee-free conversion - // still carries a real EUR principal. + // Harvest the EUR leg's real Net Amt. (the fee-excluded principal). No fee + // gate — a fee-free conversion still carries a real EUR principal. if (ccy === "EUR") { if (conversionEurAmounts.has(dateRaw)) { // A second EUR leg at this timestamp → ambiguous pairing; drop to ECB. ambiguousEurTimestamps.add(dateRaw); } else { + // Use the EUR leg's Net Amt. ONLY — it is the real principal with the fee + // EXCLUDED (the engine applies our -Fee commission on top). Gross is + // fee-INCLUSIVE, so falling back to it would double-count the fee; if Net + // is empty we omit the field and let the engine use ECB instead. const eurNet = toFiniteDecimal(fields[cols.netAmount] ?? "0"); - const eurReal = eurNet.isZero() ? toFiniteDecimal(fields[cols.grossAmount] ?? "0") : eurNet; - if (!eurReal.isZero()) conversionEurAmounts.set(dateRaw, eurReal.abs().toString()); + if (!eurNet.isZero()) conversionEurAmounts.set(dateRaw, eurNet.abs().toString()); } }