Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions src/engine/fx-fifo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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;
Expand All @@ -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).
*
Expand Down
73 changes: 56 additions & 17 deletions tests/engine/fx-fifo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
});

Expand Down
15 changes: 7 additions & 8 deletions tests/generators/report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
],
});
Expand Down
Loading