Skip to content

fix(fx): correct polarity inversion for EUR.XXX CASH trade pairs#176

Merged
GeiserX merged 2 commits into
mainfrom
fix/fx-eur-pair-polarity
May 24, 2026
Merged

fix(fx): correct polarity inversion for EUR.XXX CASH trade pairs#176
GeiserX merged 2 commits into
mainfrom
fix/fx-eur-pair-polarity

Conversation

@GeiserX
Copy link
Copy Markdown
Owner

@GeiserX GeiserX commented May 24, 2026

Summary

  • 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.
  • Detects EUR.XXX pairs via symbol/description prefix and uses tradeMoney (the actual quote-currency amount) with inverted polarity
  • Non-EUR pairs (e.g., GBP.USD with currency=GBP) continue using the original quantity-based logic

Root cause

IBKR Flex Query uses forex market convention: for EUR.USD, quantity = EUR amount (base), tradeMoney = USD amount (quote). SELL EUR.USD = selling EUR = acquiring USD. The code was treating SELL as disposing USD and using the EUR quantity as if it were USD.

This was masked because all test fixtures used AFx-noted trades (which are filtered out by isFxconv()), so no non-AFx EUR.XXX pair was ever exercised.

Impact

Users with manual (non-AFx) EUR.XXX CASH conversions were getting inverted FX events: acquisitions recorded as disposals and vice versa, with wrong amounts (EUR instead of USD). This caused phantom disposals from nonexistent lots.

Reported by @elmasvital — his PR #175 identified the symptom but proposed the wrong fix (wholesale revert of v0.39.2). This is the targeted correction.

Test plan

  • EUR.USD SELL correctly produces positive (acquiring) event using tradeMoney
  • EUR.USD BUY correctly produces negative (disposing) event using tradeMoney
  • Non-EUR pairs (USD.JPY) still use quantity with original polarity
  • All 1044 tests pass, zero regressions
  • TypeScript strict mode clean

Closes #176

Summary by CodeRabbit

  • Bug Fixes
    • Fixed FX event calculations for EUR currency pairs to correctly determine trade quantities and acquisition vs. disposal directions, improving accuracy in FX trade reporting.

Review Change Stack

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
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 24, 2026

Warning

Review limit reached

@GeiserX, we couldn't start this review because you've used your available PR reviews for now.

Your plan currently allows 1 review/hour. Refill in 17 minutes and 33 seconds.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more review capacity refills, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ec529526-a0ec-46de-86ee-c87660d73fd0

📥 Commits

Reviewing files that changed from the base of the PR and between 2be96dd and f8b84d7.

📒 Files selected for processing (2)
  • src/engine/fx-fifo.ts
  • tests/engine/fx-fifo.test.ts
📝 Walkthrough

Walkthrough

This PR corrects FX event extraction for EUR-base currency pairs by detecting EUR.xxx symbols and sourcing quantities from tradeMoney with inverted polarity, while keeping non-EUR pairs on the original quantity-based logic. Event emission is reshaped to use a derived acquiring boolean, pushing signed quantities for acquisitions and disposals.

Changes

EUR.xxx FX event extraction polarity fix

Layer / File(s) Summary
FX extraction logic
src/engine/fx-fifo.ts
extractFxEvents detects EUR.xxx pairs (symbol prefix check) and derives quantity from tradeMoney with acquiring inverted relative to trade.buySell, while non-EUR pairs use trade.quantity with original polarity. Event emission uses the acquiring boolean to emit positive quantity for acquisitions and negative for disposals.
Unit tests
tests/engine/fx-fifo.test.ts
Validates EUR.USD SELL/BUY extraction from tradeMoney with correct direction and sign, non-EUR pair extraction from quantity with original polarity, CASH-only filtering with tradeMoney normalization, and edge cases for negative amounts and hybrid account scenarios.
Report test fixture
tests/generators/report.test.ts
Manual EUR.USD trade pair corrected to SELL (negative quantity/tradeMoney) then BUY (positive quantity/tradeMoney) to produce non-zero FX gains with the revised extraction logic.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • GeiserX/DeclaRenta#171: Both PRs modify FxFifoEngine.extractFxEvents in src/engine/fx-fifo.ts; this PR adjusts EUR.xxx quantity/direction semantics while the related PR rewrites extraction to emit only from manual CASH with per-trade isFxconv filtering.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title precisely describes the main change: fixing polarity inversion specifically for EUR.XXX CASH trade pairs.
Linked Issues check ✅ Passed All linked issue requirements are met: EUR.XXX detection implemented, tradeMoney used for EUR pairs, polarity inverted for EUR trades, non-EUR pairs preserved, tests cover EUR.USD SELL/BUY and non-EUR pairs, all tests pass.
Out of Scope Changes check ✅ Passed All changes scope to fixing EUR.XXX polarity handling and supporting test updates; no unrelated alterations detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/fx-eur-pair-polarity

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 24, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.57%. Comparing base (fceacf1) to head (f8b84d7).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #176   +/-   ##
=======================================
  Coverage   97.56%   97.57%           
=======================================
  Files          38       38           
  Lines        7764     7780   +16     
  Branches     1588     1596    +8     
=======================================
+ Hits         7575     7591   +16     
  Misses        188      188           
  Partials        1        1           
Files with missing lines Coverage Δ
src/engine/fx-fifo.ts 97.67% <100.00%> (+0.18%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/engine/fx-fifo.test.ts (1)

202-216: ⚡ Quick win

Add a regression case for description-driven EUR detection.

Please add one case where symbol is non-EUR (or empty) and description starts with EUR. to lock the intended “symbol/description” detection behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/engine/fx-fifo.test.ts` around lines 202 - 216, Add a regression test
where symbol is non-EUR (or empty) but description starts with "EUR." to ensure
description-driven EUR detection still works: in the tests/engine
fx-fifo.test.ts add an it() case (similar to the existing "non-EUR pair" tests)
that constructs a trade via makeTrade with symbol "USD.JPY" (or empty) and
description "EUR.XXX" and passes it to FxFifoEngine.extractFxEvents using
rateMap, then assert events length is 1 and that the resulting event reflects
EUR detection (e.g., correct sign/quantity handling as in the BUY/SELL cases);
reference makeTrade and FxFifoEngine.extractFxEvents to locate where to add the
case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/engine/fx-fifo.ts`:
- Line 105: The current isEurBase predicate uses (trade.symbol ||
trade.description || "").toUpperCase().startsWith("EUR.") which short-circuits
on a non-EUR symbol and misses an EUR-prefixed description; update the check in
isEurBase to test both fields individually (e.g., check (trade.symbol || "") and
(trade.description || "") separately, .toUpperCase(), and OR their
.startsWith("EUR.") results) so either field can detect an EUR-base pair while
safely handling undefined values.

---

Nitpick comments:
In `@tests/engine/fx-fifo.test.ts`:
- Around line 202-216: Add a regression test where symbol is non-EUR (or empty)
but description starts with "EUR." to ensure description-driven EUR detection
still works: in the tests/engine fx-fifo.test.ts add an it() case (similar to
the existing "non-EUR pair" tests) that constructs a trade via makeTrade with
symbol "USD.JPY" (or empty) and description "EUR.XXX" and passes it to
FxFifoEngine.extractFxEvents using rateMap, then assert events length is 1 and
that the resulting event reflects EUR detection (e.g., correct sign/quantity
handling as in the BUY/SELL cases); reference makeTrade and
FxFifoEngine.extractFxEvents to locate where to add the case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a67ead75-a602-432a-9b79-8ba2b6f0b2f9

📥 Commits

Reviewing files that changed from the base of the PR and between 1b8c355 and 2be96dd.

📒 Files selected for processing (3)
  • src/engine/fx-fifo.ts
  • tests/engine/fx-fifo.test.ts
  • tests/generators/report.test.ts

Comment thread src/engine/fx-fifo.ts Outdated
@elmasvital
Copy link
Copy Markdown
Contributor

elmasvital commented May 24, 2026

Estaba preparando un PR sobre este tema pero estaba pendiente del anterior.

Me gustaría proponer una solución que a mi entender sería más limpia.

Creo que sería positivo que las correcciones debidas a paricularidades del broker no deberían complicar la mecánica del engine. Creo que deberían ser resueltas en el propio parser y entregar una estructura de datos idéntica para todos.

Tengo ensayado esto y está funcionando

function mapTrade(raw: Record<string, string>): Trade {

  // Detección de CONVERSION DIVISA: tradeType "ExchTrade" indica conversión (ej: EUR → USD)
  // Inconsistencia: IBKR la codifica como currency="USD" y tipo ¨SELL¨, pero las cantidades están expresadas en EUR.
  // En este caso, forzamos buySell a "BUY" para que el sistema lo trate como compra de moneda extranjera
  // y la cantidad en FCY se obtiene del campo Proceeds, que es el importe en moneda base (EUR) convertido a USD.


  const isConversion: boolean = (raw.transactionType === "ExchTrade" && raw.assetCategory === "CASH") ? true : false;
  if (isConversion) {
    raw.buySell = "BUY";
    raw.quantity = raw.proceeds ?? "0";
  }

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.
@GeiserX GeiserX merged commit da379f8 into main May 24, 2026
5 checks passed
@GeiserX GeiserX deleted the fix/fx-eur-pair-polarity branch May 24, 2026 16:51
@GeiserX
Copy link
Copy Markdown
Owner Author

GeiserX commented May 24, 2026

Gracias por la propuesta, @elmasvital. La idea de normalizar en el parser es atractiva conceptualmente, pero tiene algunos problemas prácticos:

1. Solo cubre una dirección. Tu código fuerza buySell = "BUY" para todas las conversiones, pero las cuentas reales tienen AMBOS sentidos: SELL EUR.USD (adquirir USD) y BUY EUR.USD (disponer USD para comprar EUR). Si fuerzas todo a BUY, los BUY EUR.USD originales se interpretarían como "adquirir USD" cuando realmente es "disponer USD" — se crearían lotes fantasma que nunca se consumen.

2. proceeds tiene signo variable. En SELL es positivo (USD recibidos), en BUY es negativo. Asignar quantity = proceeds sin lógica de signo/dirección produce importes incorrectos para BUY.

3. transactionType no está en la interfaz Trade. Se descarta en el mapeo, así que habría que duplicar la detección en el parser que ya está resuelta en el engine.

La solución que implementamos en v0.39.5 es genérica: detecta cuándo trade.currency es la divisa QUOTE del par (segundo lado del punto en EUR.USD, GBP.USD, etc.) y entonces usa tradeMoney con polaridad invertida. Funciona para CUALQUIER par y AMBAS direcciones, sin perder información ni depender de campos que se descartan en el parsing.

La diferencia con Lightyear (que sí normaliza en el parser) es que el CSV de Lightyear ya proporciona importes explícitos por divisa con signo correcto. IBKR no — hay que interpretar la semántica BASE.QUOTE, que es lógica del dominio FX y por eso vive en el engine.


Sobre "completamente roto con mis datos": v0.39.5 corrige exactamente el bug de polaridad que afectaba a tu conversión manual del 28/03 (EUR.USD SELL quantity=-998). ¿Puedes probar con la versión actual en declarenta.com y confirmarme si te funciona?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants