Skip to content

Commit 7ecfd10

Browse files
Improvement: IBFlex support cross-currency dividend import
When dividend currency differs from security currency and no direct conversion rate exists, use fxRateToBase from the CashTransaction element to calculate cross-rates via the account's base currency. Also handles minor unit securities (e.g., USD→GBX via EUR→GBP→GBX).
1 parent 8c2c49c commit 7ecfd10

File tree

3 files changed

+230
-0
lines changed

3 files changed

+230
-0
lines changed

name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/ibflex/IBFlexStatementExtractorTest.java

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
import java.io.FileOutputStream;
3737
import java.io.IOException;
3838
import java.io.InputStream;
39+
import java.math.BigDecimal;
40+
import java.math.RoundingMode;
3941
import java.nio.file.Files;
4042
import java.time.LocalDateTime;
4143
import java.util.ArrayList;
@@ -3554,4 +3556,127 @@ public void testIBFlexStatementFile27() throws IOException
35543556
// Exchange rate should be 0.01 (GBP to GBX: 1 GBP = 100 GBX, so rate is 0.01)
35553557
assertThat(unit.getExchangeRate().compareTo(new java.math.BigDecimal("0.01")), is(0));
35563558
}
3559+
3560+
@Test
3561+
public void testIBFlexStatementFile28() throws IOException
3562+
{
3563+
// Test cross-rate calculation using fxRateToBase when direct rate is missing
3564+
// Case 1: Transaction currency: USD, Security currency: GBP, Base currency: EUR
3565+
// Case 2: Transaction currency: USD, Security currency: GBX (minor unit), Base currency: EUR
3566+
// USD/EUR available via fxRateToBase, GBP/EUR available via ConversionRate
3567+
var client = new Client();
3568+
3569+
// Create security with GBP currency (security currency differs from transaction currency)
3570+
var hidr = new Security("HSBC MSCI INDONESIA UCITS ET", "GBP");
3571+
hidr.setTickerSymbol("HIDR");
3572+
hidr.setIsin("IE00B46G8275");
3573+
hidr.setWkn("86281326");
3574+
client.addSecurity(hidr);
3575+
3576+
// Create security with GBX currency (minor unit)
3577+
var eqqq = new Security("INVESCO NASDAQ-100 DIST", "GBX");
3578+
eqqq.setTickerSymbol("EQQQ");
3579+
eqqq.setIsin("IE0032077012");
3580+
eqqq.setWkn("18706552");
3581+
client.addSecurity(eqqq);
3582+
3583+
var referenceAccount = new Account("A");
3584+
referenceAccount.setCurrencyCode("EUR");
3585+
client.addAccount(referenceAccount);
3586+
3587+
var extractor = new IBFlexStatementExtractor(client);
3588+
3589+
var activityStatement = getClass().getResourceAsStream("testIBFlexStatementFile28.xml");
3590+
var tempFile = createTempFile(activityStatement);
3591+
3592+
var errors = new ArrayList<Exception>();
3593+
3594+
var results = extractor.extract(Collections.singletonList(tempFile), errors);
3595+
assertThat(errors, empty());
3596+
assertThat(countSecurities(results), is(0L)); // Securities already exist
3597+
assertThat(countBuySell(results), is(0L));
3598+
assertThat(countAccountTransactions(results), is(2L));
3599+
3600+
// Find the first dividend transaction (HIDR - USD->GBP)
3601+
List<AccountTransaction> transactions = results.stream() //
3602+
.filter(TransactionItem.class::isInstance) //
3603+
.map(item -> (AccountTransaction) ((TransactionItem) item).getSubject()) //
3604+
.filter(t -> t.getType() == AccountTransaction.Type.DIVIDENDS) //
3605+
.collect(Collectors.toList());
3606+
3607+
assertThat(transactions.size(), is(2));
3608+
3609+
// Test first transaction: HIDR (USD->GBP)
3610+
AccountTransaction hidrTransaction = transactions.stream() //
3611+
.filter(t -> "HIDR".equals(t.getSecurity().getTickerSymbol())) //
3612+
.findFirst() //
3613+
.orElseThrow(() -> new AssertionError("HIDR dividend transaction not found"));
3614+
3615+
assertThat(hidrTransaction.getType(), is(AccountTransaction.Type.DIVIDENDS));
3616+
assertThat(hidrTransaction.getDateTime(), is(LocalDateTime.parse("2025-03-04T00:00")));
3617+
3618+
assertThat(hidrTransaction.getCurrencyCode(), is("USD"));
3619+
assertThat(hidrTransaction.getAmount(), is(1815L)); // 18.15 USD
3620+
assertThat(hidrTransaction.getSecurity().getCurrencyCode(), is("GBP"));
3621+
3622+
// Verify GROSS_VALUE unit exists and has correct conversion
3623+
// USD/EUR = 0.94106 (from fxRateToBase)
3624+
// GBP/EUR = 1.2041 (from ConversionRate on 20250304)
3625+
// USD/GBP = (USD/EUR) / (GBP/EUR) = 0.94106 / 1.2041 ≈ 0.781546
3626+
// After inversion in setAmount: GBP/USD ≈ 1.2795
3627+
var hidrGrossValueUnit = hidrTransaction.getUnit(Unit.Type.GROSS_VALUE);
3628+
assertThat("GROSS_VALUE unit should be present for USD->GBP conversion", hidrGrossValueUnit.isPresent(), is(true));
3629+
3630+
var hidrUnit = hidrGrossValueUnit.get();
3631+
assertThat(hidrUnit.getAmount().getCurrencyCode(), is("USD"));
3632+
assertThat(hidrUnit.getAmount().getAmount(), is(1815L)); // 18.15 USD
3633+
assertThat(hidrUnit.getForex().getCurrencyCode(), is("GBP"));
3634+
assertThat(hidrUnit.getForex().getAmount(), is(1419L)); // 14.19 GBP (rounded)
3635+
3636+
BigDecimal hidrExpectedRate = new BigDecimal("1.2795145899");
3637+
BigDecimal tolerance = new BigDecimal("0.0000001");
3638+
assertThat(hidrUnit.getExchangeRate().subtract(hidrExpectedRate).abs().compareTo(tolerance) < 0, is(true));
3639+
3640+
// Test second transaction: EQQQ (USD->GBX via GBP)
3641+
AccountTransaction eqqqTransaction = transactions.stream() //
3642+
.filter(t -> "EQQQ".equals(t.getSecurity().getTickerSymbol())) //
3643+
.findFirst() //
3644+
.orElseThrow(() -> new AssertionError("EQQQ dividend transaction not found"));
3645+
3646+
assertThat(eqqqTransaction.getType(), is(AccountTransaction.Type.DIVIDENDS));
3647+
assertThat(eqqqTransaction.getDateTime(), is(LocalDateTime.parse("2025-03-21T00:00")));
3648+
3649+
assertThat(eqqqTransaction.getCurrencyCode(), is("USD"));
3650+
assertThat(eqqqTransaction.getAmount(), is(136L)); // 1.36 USD
3651+
assertThat(eqqqTransaction.getSecurity().getCurrencyCode(), is("GBX"));
3652+
3653+
// Verify GROSS_VALUE unit exists and has correct conversion
3654+
// USD/EUR = 0.92464 (from fxRateToBase)
3655+
// GBP/EUR = 1.1726 (from ConversionRate on 20250321)
3656+
// USD/GBP = (USD/EUR) / (GBP/EUR) = 0.92464 / 1.1726 ≈ 0.7887
3657+
// GBX/GBP = 0.01 (from FixedExchangeRateProvider)
3658+
// USD/GBX = USD/GBP / GBX/GBP = 0.7887 / 0.01 = 78.87
3659+
// After inversion in setAmount: GBX/USD ≈ 0.01268
3660+
var eqqqGrossValueUnit = eqqqTransaction.getUnit(Unit.Type.GROSS_VALUE);
3661+
assertThat("GROSS_VALUE unit should be present for USD->GBX conversion", eqqqGrossValueUnit.isPresent(), is(true));
3662+
3663+
var eqqqUnit = eqqqGrossValueUnit.get();
3664+
assertThat(eqqqUnit.getAmount().getCurrencyCode(), is("USD"));
3665+
assertThat(eqqqUnit.getAmount().getAmount(), is(136L)); // 1.36 USD
3666+
assertThat(eqqqUnit.getForex().getCurrencyCode(), is("GBX"));
3667+
assertThat(eqqqUnit.getForex().getAmount(), is(10724L)); // 107.24 GBX (rounded)
3668+
3669+
// Exchange rate calculation (with 10 decimal precision, HALF_DOWN in divisions):
3670+
// USD/EUR = 0.92464, GBP/EUR = 1.1726
3671+
// USD/GBP = 0.92464 / 1.1726 = 0.7887030844... (exact)
3672+
// With 10 decimals HALF_DOWN: 0.7887030844
3673+
// USD/GBX = 0.7887030844 / 0.01 = 78.87030844
3674+
// After inversion in setAmount (10 decimals, HALF_DOWN): GBX/USD = 1 / 78.87030844
3675+
// Calculate expected rate with same precision as implementation
3676+
BigDecimal usdToGbp = new BigDecimal("0.92464").divide(new BigDecimal("1.1726"), 10, RoundingMode.HALF_DOWN);
3677+
BigDecimal usdToGbx = usdToGbp.divide(new BigDecimal("0.01"), 10, RoundingMode.HALF_DOWN);
3678+
BigDecimal eqqqExpectedRate = BigDecimal.ONE.divide(usdToGbx, 10, RoundingMode.HALF_DOWN);
3679+
BigDecimal eqqqTolerance = new BigDecimal("0.0000001"); // Same tolerance as HIDR test
3680+
assertThat(eqqqUnit.getExchangeRate().subtract(eqqqExpectedRate).abs().compareTo(eqqqTolerance) < 0, is(true));
3681+
}
35573682
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<FlexQueryResponse queryName="PP" type="AF">
2+
<FlexStatements count="1">
3+
<FlexStatement accountId="U1234567" fromDate="20250201" toDate="20250331" period="LastQuarter" whenGenerated="20250310;120000">
4+
<AccountInformation accountId="U1234567" acctAlias="A" model="" currency="EUR" name="John Doe" accountType="Individual" customerType="Individual" />
5+
<CashTransactions>
6+
<CashTransaction accountId="U1234567" acctAlias="" model="" currency="USD" fxRateToBase="0.94106" assetCategory="STK" symbol="HIDR" description="HIDR(IE00B46G8275) CASH DIVIDEND USD 0.9076 PER SHARE (Mixed Income)" conid="86281326" securityID="IE00B46G8275" securityIDType="ISIN" isin="IE00B46G8275" amount="18.15" type="Dividends" reportDate="20250304" />
7+
<CashTransaction accountId="U1234567" acctAlias="" model="" currency="USD" fxRateToBase="0.92464" assetCategory="STK" subCategory="ETF" symbol="EQQQ" description="EQQQ(IE0032077012) CASH DIVIDEND USD 0.4531 PER SHARE (Mixed Income)" conid="18706552" securityID="IE0032077012" securityIDType="ISIN" isin="IE0032077012" amount="1.36" type="Dividends" reportDate="20250321" />
8+
</CashTransactions>
9+
<ConversionRates>
10+
<ConversionRate reportDate="20250304" fromCurrency="GBP" toCurrency="EUR" rate="1.2041" />
11+
<ConversionRate reportDate="20250321" fromCurrency="GBP" toCurrency="EUR" rate="1.1726" />
12+
</ConversionRates>
13+
</FlexStatement>
14+
</FlexStatements>
15+
</FlexQueryResponse>

name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/ibflex/IBFlexStatementExtractor.java

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,13 +927,78 @@ private BigDecimal getExchangeRate(Element element, String fromCurrency, String
927927
return asExchangeRate(element.getAttribute("fxRateToBase"));
928928
}
929929

930+
// Before trying accountCurrency conversion, check if toCurrency is a minor unit
931+
// (e.g., GBX is minor unit of GBP). If so, convert to the major unit first.
932+
String toMajorUnit = findMajorUnitCurrency(toCurrency);
933+
if (toMajorUnit != null)
934+
{
935+
// Convert fromCurrency -> toMajorUnit via accountCurrency
936+
// Then apply minor unit conversion
937+
Pair<String, String> fromKey = new Pair<>(dateStr, fromCurrency + "-" + accountCurrency);
938+
Pair<String, String> toKey = new Pair<>(dateStr, toMajorUnit + "-" + accountCurrency);
939+
940+
BigDecimal fromRate = conversionRates.get(fromKey);
941+
// If fromRate is not in conversionRates, try using fxRateToBase from the element
942+
// (fxRateToBase is the rate from transaction currency to base currency)
943+
if (fromRate == null && element.hasAttribute("fxRateToBase"))
944+
{
945+
fromRate = asExchangeRate(element.getAttribute("fxRateToBase"));
946+
}
947+
BigDecimal majorUnitRate = conversionRates.get(toKey);
948+
949+
if (fromRate != null && majorUnitRate != null)
950+
{
951+
// Calculate fromCurrency -> toMajorUnit
952+
BigDecimal toMajorUnitRate = fromRate.divide(majorUnitRate, 10, RoundingMode.HALF_DOWN);
953+
// Apply minor unit conversion: toMajorUnitRate / minorUnitRate
954+
// (e.g., USD->GBP / GBX->GBP = USD->GBX)
955+
BigDecimal minorUnitRate = getUnitExchangeRate(toCurrency, toMajorUnit);
956+
if (minorUnitRate != null)
957+
{
958+
return toMajorUnitRate.divide(minorUnitRate, 10, RoundingMode.HALF_DOWN);
959+
}
960+
}
961+
}
962+
963+
// Check if fromCurrency is a minor unit (e.g., GBX -> USD)
964+
String fromMajorUnit = findMajorUnitCurrency(fromCurrency);
965+
if (fromMajorUnit != null)
966+
{
967+
// First convert fromCurrency (minor) -> fromMajorUnit (major)
968+
// Then convert fromMajorUnit -> toCurrency via accountCurrency
969+
BigDecimal minorToMajorRate = getUnitExchangeRate(fromCurrency, fromMajorUnit);
970+
if (minorToMajorRate != null)
971+
{
972+
Pair<String, String> fromKey = new Pair<>(dateStr, fromMajorUnit + "-" + accountCurrency);
973+
Pair<String, String> toKey = new Pair<>(dateStr, toCurrency + "-" + accountCurrency);
974+
975+
BigDecimal fromMajorRate = conversionRates.get(fromKey);
976+
BigDecimal toRate = conversionRates.get(toKey);
977+
978+
if (fromMajorRate != null && toRate != null)
979+
{
980+
// Calculate fromMajorUnit -> toCurrency
981+
BigDecimal majorToTargetRate = fromMajorRate.divide(toRate, 10, RoundingMode.HALF_DOWN);
982+
// Combine: fromCurrency -> fromMajorUnit -> toCurrency
983+
// (e.g., GBX->GBP * GBP->USD = GBX->USD)
984+
return minorToMajorRate.multiply(majorToTargetRate);
985+
}
986+
}
987+
}
988+
930989
// Attempt to calculate cross rate via accountCurrency. No use
931990
// in trying a different intermediate currency, it seems like
932991
// toCurrency is only ever the account's base.
933992
Pair<String, String> fromKey = new Pair<>(dateStr, fromCurrency + "-" + accountCurrency);
934993
Pair<String, String> toKey = new Pair<>(dateStr, toCurrency + "-" + accountCurrency);
935994

936995
BigDecimal fromRate = conversionRates.get(fromKey);
996+
// If fromRate is not in conversionRates, try using fxRateToBase from the element
997+
// (fxRateToBase is the rate from transaction currency to base currency)
998+
if (fromRate == null && element.hasAttribute("fxRateToBase"))
999+
{
1000+
fromRate = asExchangeRate(element.getAttribute("fxRateToBase"));
1001+
}
9371002
BigDecimal toRate = conversionRates.get(toKey);
9381003

9391004
if (fromRate != null && toRate != null)
@@ -948,6 +1013,31 @@ private BigDecimal getExchangeRate(Element element, String fromCurrency, String
9481013
return null;
9491014
}
9501015

1016+
/**
1017+
* Returns the major unit currency for a given minor unit currency, or null
1018+
* if the currency is not a known minor unit.
1019+
*
1020+
* @param minorUnitCurrency The minor unit currency (e.g., "GBX")
1021+
* @return The major unit currency (e.g., "GBP"), or null if not a known minor unit
1022+
*/
1023+
private String findMajorUnitCurrency(String minorUnitCurrency)
1024+
{
1025+
var provider = new FixedExchangeRateProvider();
1026+
1027+
for (var series : provider.getAvailableTimeSeries(null))
1028+
{
1029+
if (series.getRates().isEmpty())
1030+
continue;
1031+
1032+
if (series.getBaseCurrency().equals(minorUnitCurrency) && series.getTermCurrency() != null)
1033+
{
1034+
return series.getTermCurrency();
1035+
}
1036+
}
1037+
1038+
return null;
1039+
}
1040+
9511041
/**
9521042
* Returns the exchange rate for currency pairs with a fixed relationship
9531043
* (e.g. GBX/GBP) using FixedExchangeRateProvider. Handles both directions.

0 commit comments

Comments
 (0)