diff --git a/src/engine/fx-fifo.ts b/src/engine/fx-fifo.ts index 17e8106..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 — @@ -98,7 +101,18 @@ export class FxFifoEngine { const date = normalizeDate(trade.settlementDate || trade.tradeDate); const ecbRate = getEcbRate(rateMap, date, trade.currency); - const quantity = new Decimal(trade.quantity).abs(); + + const quoteIsTarget = FxFifoEngine.isCurrencyQuote(trade); + let amount: Decimal; + let acquiring: boolean; + + if (quoteIsTarget) { + 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,16 +127,29 @@ 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 }); } } 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 027b997..ef4d20f 100644 --- a/tests/engine/fx-fifo.test.ts +++ b/tests/engine/fx-fifo.test.ts @@ -181,24 +181,63 @@ 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("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 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" }), @@ -218,25 +257,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 +485,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 +535,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", }), ], });