From 2be96dd248b147939d072aec7785dc24ff015c93 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 24 May 2026 18:12:25 +0200 Subject: [PATCH 1/2] fix(fx): correct polarity inversion for EUR.XXX CASH trade pairs For EUR.USD (and other EUR-base pairs), IBKR's quantity field is in EUR (the base currency), not in trade.currency (the quote). Additionally, SELL means selling EUR (acquiring the quote), not disposing it. The fix detects EUR.XXX pairs via symbol/description prefix and: - Uses tradeMoney (the actual quote-currency amount) instead of quantity - Inverts polarity: SELL = acquiring, BUY = disposing Non-EUR pairs continue using the original quantity-based logic. Closes #176 --- src/engine/fx-fifo.ts | 22 ++++++++++++--- tests/engine/fx-fifo.test.ts | 50 ++++++++++++++++++++++----------- tests/generators/report.test.ts | 15 +++++----- 3 files changed, 58 insertions(+), 29 deletions(-) diff --git a/src/engine/fx-fifo.ts b/src/engine/fx-fifo.ts index 17e8106..d3d25d9 100644 --- a/src/engine/fx-fifo.ts +++ b/src/engine/fx-fifo.ts @@ -98,7 +98,21 @@ export class FxFifoEngine { const date = normalizeDate(trade.settlementDate || trade.tradeDate); const ecbRate = getEcbRate(rateMap, date, trade.currency); - const quantity = new Decimal(trade.quantity).abs(); + + // EUR.XXX pairs: quantity is in EUR (base), tradeMoney is in trade.currency (quote). + // SELL EUR.XXX = selling EUR = acquiring quote currency. + // Non-EUR pairs (e.g. GBP.USD with currency=GBP): quantity is already in trade.currency. + const isEurBase = (trade.symbol || trade.description || "").toUpperCase().startsWith("EUR."); + let amount: Decimal; + let acquiring: boolean; + + if (isEurBase) { + amount = new Decimal(trade.tradeMoney).abs(); + acquiring = trade.buySell === "SELL"; + } else { + amount = new Decimal(trade.quantity).abs(); + acquiring = trade.buySell === "BUY"; + } // Commission increases cost basis (BUY) or reduces proceeds (SELL) let commissionEur: Decimal | undefined; @@ -113,10 +127,10 @@ export class FxFifoEngine { } } - if (trade.buySell === "BUY") { - events.push({ date, currency: trade.currency, quantity, ecbRate, trigger: "conversion", commissionEur }); + if (acquiring) { + events.push({ date, currency: trade.currency, quantity: amount, ecbRate, trigger: "conversion", commissionEur }); } else { - events.push({ date, currency: trade.currency, quantity: quantity.negated(), ecbRate, trigger: "conversion", commissionEur }); + events.push({ date, currency: trade.currency, quantity: amount.negated(), ecbRate, trigger: "conversion", commissionEur }); } } diff --git a/tests/engine/fx-fifo.test.ts b/tests/engine/fx-fifo.test.ts index 027b997..d9b949b 100644 --- a/tests/engine/fx-fifo.test.ts +++ b/tests/engine/fx-fifo.test.ts @@ -181,24 +181,40 @@ describe("FxFifoEngine", () => { }); describe("extractFxEvents", () => { - it("should extract BUY CASH as positive FX event", () => { - const trades = [makeTrade({ buySell: "BUY", quantity: "1000" })]; + it("EUR.USD SELL = acquiring USD (positive event, uses tradeMoney)", () => { + const trades = [makeTrade({ buySell: "SELL", quantity: "-998", tradeMoney: "-1080.24" })]; const events = FxFifoEngine.extractFxEvents(trades, rateMap); expect(events).toHaveLength(1); - expect(events[0]!.quantity.toString()).toBe("1000"); + expect(events[0]!.quantity.toString()).toBe("1080.24"); expect(events[0]!.trigger).toBe("conversion"); }); - it("should extract SELL CASH as negative FX event", () => { - const trades = [makeTrade({ buySell: "SELL", quantity: "1000" })]; + it("EUR.USD BUY = disposing USD (negative event, uses tradeMoney)", () => { + const trades = [makeTrade({ buySell: "BUY", quantity: "1000", tradeMoney: "1080" })]; const events = FxFifoEngine.extractFxEvents(trades, rateMap); expect(events).toHaveLength(1); - expect(events[0]!.quantity.toString()).toBe("-1000"); + expect(events[0]!.quantity.toString()).toBe("-1080"); expect(events[0]!.trigger).toBe("conversion"); }); + it("non-EUR pair: BUY = acquiring (positive event, uses quantity)", () => { + const trades = [makeTrade({ symbol: "USD.JPY", description: "USD.JPY", currency: "USD", buySell: "BUY", quantity: "5000", tradeMoney: "5000" })]; + const events = FxFifoEngine.extractFxEvents(trades, rateMap); + + expect(events).toHaveLength(1); + expect(events[0]!.quantity.toString()).toBe("5000"); + }); + + it("non-EUR pair: SELL = disposing (negative event, uses quantity)", () => { + const trades = [makeTrade({ symbol: "USD.JPY", description: "USD.JPY", currency: "USD", buySell: "SELL", quantity: "-5000", tradeMoney: "-5000" })]; + const events = FxFifoEngine.extractFxEvents(trades, rateMap); + + expect(events).toHaveLength(1); + expect(events[0]!.quantity.toString()).toBe("-5000"); + }); + it("should skip FXCONV trades (automatic conversions)", () => { const trades = [ makeTrade({ description: "FXCONV" }), @@ -218,25 +234,25 @@ describe("FxFifoEngine", () => { expect(events[0]!.date).toBe("2025-03-15"); }); - it("should handle negative quantity on BUY via .abs() normalization", () => { + it("should handle negative tradeMoney on EUR.USD SELL via .abs()", () => { const trades = [ - makeTrade({ buySell: "BUY", quantity: "-500" }), + makeTrade({ buySell: "SELL", quantity: "-500", tradeMoney: "-540" }), ]; const events = FxFifoEngine.extractFxEvents(trades, rateMap); expect(events).toHaveLength(1); - expect(events[0]!.quantity.toString()).toBe("500"); + expect(events[0]!.quantity.toString()).toBe("540"); }); it("should only extract CASH trades, ignoring STK trades entirely", () => { const trades = [ - makeTrade({ assetCategory: "CASH", description: "EUR.USD", buySell: "BUY", quantity: "10000", currency: "USD" }), + makeTrade({ assetCategory: "CASH", description: "EUR.USD", buySell: "SELL", quantity: "-998", tradeMoney: "-1080", currency: "USD" }), makeTrade({ assetCategory: "STK", symbol: "AAPL", buySell: "BUY", tradeMoney: "5000", currency: "USD" }), ]; const events = FxFifoEngine.extractFxEvents(trades, rateMap); expect(events).toHaveLength(1); expect(events[0]!.trigger).toBe("conversion"); - expect(events[0]!.quantity.toString()).toBe("10000"); + expect(events[0]!.quantity.toString()).toBe("1080"); }); it("should skip EUR trades", () => { @@ -446,11 +462,11 @@ describe("FxFifoEngine", () => { it("should skip FXCONV-described CASH trades", () => { const trades = [ makeTrade({ assetCategory: "CASH", description: "FXCONV", currency: "USD" }), - makeTrade({ assetCategory: "CASH", description: "EUR.USD", buySell: "BUY", quantity: "1000", currency: "USD" }), + makeTrade({ assetCategory: "CASH", description: "EUR.USD", buySell: "SELL", quantity: "-1000", tradeMoney: "-1080", currency: "USD" }), ]; const events = FxFifoEngine.extractFxEvents(trades, rateMap); expect(events).toHaveLength(1); - expect(events[0]!.quantity.toString()).toBe("1000"); + expect(events[0]!.quantity.toString()).toBe("1080"); }); it("should skip AFx-noted CASH trades", () => { @@ -496,14 +512,14 @@ describe("FxFifoEngine", () => { it("should handle hybrid account: manual conversions processed, AFx skipped", () => { const trades = [ - makeTrade({ tradeID: "1", assetCategory: "CASH", description: "EUR.USD", notes: "AFx", buySell: "BUY", quantity: "500", currency: "USD" }), - makeTrade({ tradeID: "2", assetCategory: "CASH", description: "EUR.USD", buySell: "BUY", quantity: "1000", currency: "USD" }), + makeTrade({ tradeID: "1", assetCategory: "CASH", description: "EUR.USD", notes: "AFx", buySell: "SELL", quantity: "-500", tradeMoney: "-540", currency: "USD" }), + makeTrade({ tradeID: "2", assetCategory: "CASH", description: "EUR.USD", buySell: "SELL", quantity: "-1000", tradeMoney: "-1080", currency: "USD" }), makeTrade({ tradeID: "3", assetCategory: "STK", symbol: "AAPL", buySell: "BUY", tradeMoney: "800", currency: "USD" }), ]; const events = FxFifoEngine.extractFxEvents(trades, rateMap); - // Only the manual CASH BUY (tradeID 2) generates an event; AFx skipped, STK ignored + // Only the manual CASH SELL (tradeID 2) generates an event; AFx skipped, STK ignored expect(events).toHaveLength(1); - expect(events[0]!.quantity.toString()).toBe("1000"); + expect(events[0]!.quantity.toString()).toBe("1080"); expect(events[0]!.trigger).toBe("conversion"); }); diff --git a/tests/generators/report.test.ts b/tests/generators/report.test.ts index 4befc44..5475dce 100644 --- a/tests/generators/report.test.ts +++ b/tests/generators/report.test.ts @@ -238,21 +238,20 @@ describe("generateTaxReport", () => { "2025-06-15": "0.9500", }); - // Include a manual CASH BUY (acquire USD) and CASH SELL (dispose USD) - // to bypass auto-convert detection and produce real FX disposals + // SELL EUR.USD = selling EUR to acquire USD; BUY EUR.USD = buying EUR by disposing USD const statement = makeStatement({ trades: [ makeTrade({ - tradeID: "fx-buy", tradeDate: "2025-01-10", settlementDate: "2025-01-10", + tradeID: "fx-sell", tradeDate: "2025-01-10", settlementDate: "2025-01-10", symbol: "EUR.USD", description: "EUR.USD", isin: "", assetCategory: "CASH", - currency: "USD", quantity: "5000", tradePrice: "1.0870", tradeMoney: "5000", - proceeds: "-5000", buySell: "BUY", exchange: "IDEALFX", + currency: "USD", quantity: "-5000", tradePrice: "1.0870", tradeMoney: "-5435", + proceeds: "5435", buySell: "SELL", exchange: "IDEALFX", }), makeTrade({ - tradeID: "fx-sell", tradeDate: "2025-06-15", settlementDate: "2025-06-15", + tradeID: "fx-buy", tradeDate: "2025-06-15", settlementDate: "2025-06-15", symbol: "EUR.USD", description: "EUR.USD", isin: "", assetCategory: "CASH", - currency: "USD", quantity: "-5000", tradePrice: "1.0526", tradeMoney: "-5000", - proceeds: "5000", buySell: "SELL", exchange: "IDEALFX", + currency: "USD", quantity: "5000", tradePrice: "1.0526", tradeMoney: "5263", + proceeds: "-5263", buySell: "BUY", exchange: "IDEALFX", }), ], }); From f8b84d73670814737f1dd4f43e1528f9a78748a6 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 24 May 2026 18:49:42 +0200 Subject: [PATCH 2/2] fix(fx): generalize polarity detection for all BASE.QUOTE pairs Extracts isCurrencyQuote() to detect when trade.currency matches the quote side of any pair (not just EUR.XXX). Fixes the same latent bug for cross-rate pairs like GBP.USD with currency=USD. Updates JSDoc, adds EUR.GBP and GBP.USD cross-rate tests, ensures tradeMoney differs from quantity in test assertions to prove correct field is read. --- src/engine/fx-fifo.ts | 29 +++++++++++++++++++++-------- tests/engine/fx-fifo.test.ts | 31 +++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/engine/fx-fifo.ts b/src/engine/fx-fifo.ts index d3d25d9..6447dfb 100644 --- a/src/engine/fx-fifo.ts +++ b/src/engine/fx-fifo.ts @@ -76,9 +76,12 @@ export class FxFifoEngine { /** * Extract FX events from trades. * - * Only explicit CASH trades generate FX events: - * - BUY CASH in USD = acquiring USD (add lot) - * - SELL CASH in USD = disposing USD (consume lots) + * Only explicit CASH trades generate FX events. For BASE.QUOTE pairs: + * - If trade.currency matches the quote: quantity is in the base (wrong + * currency), so we use tradeMoney (in quote = trade.currency) and invert + * polarity (SELL base = acquiring quote, BUY base = disposing quote). + * - If trade.currency matches the base: quantity is already in + * trade.currency, BUY = acquiring, SELL = disposing. * * FXCONV/AFx-marked trades (automatic broker conversions for settlement) * are skipped per-trade via isFxconv(). No global auto-convert detection — @@ -99,14 +102,11 @@ export class FxFifoEngine { const date = normalizeDate(trade.settlementDate || trade.tradeDate); const ecbRate = getEcbRate(rateMap, date, trade.currency); - // EUR.XXX pairs: quantity is in EUR (base), tradeMoney is in trade.currency (quote). - // SELL EUR.XXX = selling EUR = acquiring quote currency. - // Non-EUR pairs (e.g. GBP.USD with currency=GBP): quantity is already in trade.currency. - const isEurBase = (trade.symbol || trade.description || "").toUpperCase().startsWith("EUR."); + const quoteIsTarget = FxFifoEngine.isCurrencyQuote(trade); let amount: Decimal; let acquiring: boolean; - if (isEurBase) { + if (quoteIsTarget) { amount = new Decimal(trade.tradeMoney).abs(); acquiring = trade.buySell === "SELL"; } else { @@ -137,6 +137,19 @@ export class FxFifoEngine { return events; } + /** + * Detect if trade.currency is the QUOTE side of a BASE.QUOTE pair. + * When true, quantity is in the base (wrong currency for lot tracking) + * and tradeMoney is in the quote (= trade.currency). + */ + private static isCurrencyQuote(trade: Trade): boolean { + const sym = (trade.symbol || trade.description || "").toUpperCase(); + const dot = sym.indexOf("."); + if (dot === -1) return false; + const quote = sym.slice(dot + 1); + return quote === trade.currency.toUpperCase(); + } + /** * Extract FX events from cash transactions (dividends, interest). * diff --git a/tests/engine/fx-fifo.test.ts b/tests/engine/fx-fifo.test.ts index d9b949b..ef4d20f 100644 --- a/tests/engine/fx-fifo.test.ts +++ b/tests/engine/fx-fifo.test.ts @@ -199,22 +199,45 @@ describe("FxFifoEngine", () => { expect(events[0]!.trigger).toBe("conversion"); }); - it("non-EUR pair: BUY = acquiring (positive event, uses quantity)", () => { - const trades = [makeTrade({ symbol: "USD.JPY", description: "USD.JPY", currency: "USD", buySell: "BUY", quantity: "5000", tradeMoney: "5000" })]; + it("EUR.GBP SELL = acquiring GBP (positive, uses tradeMoney)", () => { + const rateMapWithGbp: EcbRateMap = new Map([ + ["2025-03-15", new Map([["GBP", new Decimal("1.15")]])], + ]); + const trades = [makeTrade({ symbol: "EUR.GBP", description: "EUR.GBP", currency: "GBP", commissionCurrency: "EUR", buySell: "SELL", quantity: "-2000", tradeMoney: "-1700", settlementDate: "20250315" })]; + const events = FxFifoEngine.extractFxEvents(trades, rateMapWithGbp); + + expect(events).toHaveLength(1); + expect(events[0]!.quantity.toString()).toBe("1700"); + expect(events[0]!.currency).toBe("GBP"); + }); + + it("non-EUR base pair where currency=base: BUY = acquiring (uses quantity)", () => { + // USD.JPY with currency=USD — currency matches base, so quantity (in USD) is the right field + const trades = [makeTrade({ symbol: "USD.JPY", description: "USD.JPY", currency: "USD", buySell: "BUY", quantity: "5000", tradeMoney: "625000", settlementDate: "20250315" })]; const events = FxFifoEngine.extractFxEvents(trades, rateMap); expect(events).toHaveLength(1); expect(events[0]!.quantity.toString()).toBe("5000"); }); - it("non-EUR pair: SELL = disposing (negative event, uses quantity)", () => { - const trades = [makeTrade({ symbol: "USD.JPY", description: "USD.JPY", currency: "USD", buySell: "SELL", quantity: "-5000", tradeMoney: "-5000" })]; + it("non-EUR base pair where currency=base: SELL = disposing (uses quantity)", () => { + const trades = [makeTrade({ symbol: "USD.JPY", description: "USD.JPY", currency: "USD", buySell: "SELL", quantity: "5000", tradeMoney: "625000", settlementDate: "20250315" })]; const events = FxFifoEngine.extractFxEvents(trades, rateMap); expect(events).toHaveLength(1); expect(events[0]!.quantity.toString()).toBe("-5000"); }); + it("cross-rate pair where currency=quote: GBP.USD with currency=USD (uses tradeMoney, inverted)", () => { + // GBP.USD: quantity=GBP (base), tradeMoney=USD (quote=currency). SELL = acquiring USD. + const trades = [makeTrade({ symbol: "GBP.USD", description: "GBP.USD", currency: "USD", buySell: "SELL", quantity: "-3000", tradeMoney: "-3900", settlementDate: "20250315" })]; + const events = FxFifoEngine.extractFxEvents(trades, rateMap); + + expect(events).toHaveLength(1); + expect(events[0]!.quantity.toString()).toBe("3900"); + expect(events[0]!.currency).toBe("USD"); + }); + it("should skip FXCONV trades (automatic conversions)", () => { const trades = [ makeTrade({ description: "FXCONV" }),