Skip to content

Commit cbc6227

Browse files
georgemac-labsbuchen
authored andcommitted
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 8319a86 commit cbc6227

3 files changed

Lines changed: 228 additions & 0 deletions

File tree

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;
@@ -3558,4 +3560,127 @@ public void testIBFlexStatementFile27() throws IOException
35583560
// 0.01)
35593561
assertThat(unit.getExchangeRate().compareTo(new java.math.BigDecimal("0.01")), is(0));
35603562
}
3563+
3564+
@Test
3565+
public void testIBFlexStatementFile28() throws IOException
3566+
{
3567+
// Test cross-rate calculation using fxRateToBase when direct rate is missing
3568+
// Case 1: Transaction currency: USD, Security currency: GBP, Base currency: EUR
3569+
// Case 2: Transaction currency: USD, Security currency: GBX (minor unit), Base currency: EUR
3570+
// USD/EUR available via fxRateToBase, GBP/EUR available via ConversionRate
3571+
var client = new Client();
3572+
3573+
// Create security with GBP currency (security currency differs from transaction currency)
3574+
var hidr = new Security("HSBC MSCI INDONESIA UCITS ET", "GBP");
3575+
hidr.setTickerSymbol("HIDR");
3576+
hidr.setIsin("IE00B46G8275");
3577+
hidr.setWkn("86281326");
3578+
client.addSecurity(hidr);
3579+
3580+
// Create security with GBX currency (minor unit)
3581+
var eqqq = new Security("INVESCO NASDAQ-100 DIST", "GBX");
3582+
eqqq.setTickerSymbol("EQQQ");
3583+
eqqq.setIsin("IE0032077012");
3584+
eqqq.setWkn("18706552");
3585+
client.addSecurity(eqqq);
3586+
3587+
var referenceAccount = new Account("A");
3588+
referenceAccount.setCurrencyCode("EUR");
3589+
client.addAccount(referenceAccount);
3590+
3591+
var extractor = new IBFlexStatementExtractor(client);
3592+
3593+
var activityStatement = getClass().getResourceAsStream("testIBFlexStatementFile28.xml");
3594+
var tempFile = createTempFile(activityStatement);
3595+
3596+
var errors = new ArrayList<Exception>();
3597+
3598+
var results = extractor.extract(Collections.singletonList(tempFile), errors);
3599+
assertThat(errors, empty());
3600+
assertThat(countSecurities(results), is(0L)); // Securities already exist
3601+
assertThat(countBuySell(results), is(0L));
3602+
assertThat(countAccountTransactions(results), is(2L));
3603+
3604+
// Find the first dividend transaction (HIDR - USD->GBP)
3605+
List<AccountTransaction> transactions = results.stream() //
3606+
.filter(TransactionItem.class::isInstance) //
3607+
.map(item -> (AccountTransaction) ((TransactionItem) item).getSubject()) //
3608+
.filter(t -> t.getType() == AccountTransaction.Type.DIVIDENDS) //
3609+
.toList();
3610+
3611+
assertThat(transactions.size(), is(2));
3612+
3613+
// Test first transaction: HIDR (USD->GBP)
3614+
AccountTransaction hidrTransaction = transactions.stream() //
3615+
.filter(t -> "HIDR".equals(t.getSecurity().getTickerSymbol())) //
3616+
.findFirst() //
3617+
.orElseThrow(() -> new AssertionError("HIDR dividend transaction not found"));
3618+
3619+
assertThat(hidrTransaction.getType(), is(AccountTransaction.Type.DIVIDENDS));
3620+
assertThat(hidrTransaction.getDateTime(), is(LocalDateTime.parse("2025-03-04T00:00")));
3621+
3622+
assertThat(hidrTransaction.getCurrencyCode(), is("USD"));
3623+
assertThat(hidrTransaction.getAmount(), is(18_15L)); // 18.15 USD
3624+
assertThat(hidrTransaction.getSecurity().getCurrencyCode(), is("GBP"));
3625+
3626+
// Verify GROSS_VALUE unit exists and has correct conversion
3627+
// USD/EUR = 0.94106 (from fxRateToBase)
3628+
// GBP/EUR = 1.2041 (from ConversionRate on 20250304)
3629+
// USD/GBP = (USD/EUR) / (GBP/EUR) = 0.94106 / 1.2041 ≈ 0.781546
3630+
// After inversion in setAmount: GBP/USD ≈ 1.2795
3631+
var hidrGrossValueUnit = hidrTransaction.getUnit(Unit.Type.GROSS_VALUE);
3632+
assertThat("GROSS_VALUE unit should be present for USD->GBP conversion", hidrGrossValueUnit.isPresent(), is(true));
3633+
3634+
var hidrUnit = hidrGrossValueUnit.get();
3635+
assertThat(hidrUnit.getAmount().getCurrencyCode(), is("USD"));
3636+
assertThat(hidrUnit.getAmount().getAmount(), is(18_15L)); // 18.15 USD
3637+
assertThat(hidrUnit.getForex().getCurrencyCode(), is("GBP"));
3638+
assertThat(hidrUnit.getForex().getAmount(), is(14_19L)); // 14.19 GBP (rounded)
3639+
3640+
BigDecimal hidrExpectedRate = new BigDecimal("1.2795145899");
3641+
BigDecimal tolerance = new BigDecimal("0.0000001");
3642+
assertThat(hidrUnit.getExchangeRate().subtract(hidrExpectedRate).abs().compareTo(tolerance) < 0, is(true));
3643+
3644+
// Test second transaction: EQQQ (USD->GBX via GBP)
3645+
AccountTransaction eqqqTransaction = transactions.stream() //
3646+
.filter(t -> "EQQQ".equals(t.getSecurity().getTickerSymbol())) //
3647+
.findFirst() //
3648+
.orElseThrow(() -> new AssertionError("EQQQ dividend transaction not found"));
3649+
3650+
assertThat(eqqqTransaction.getType(), is(AccountTransaction.Type.DIVIDENDS));
3651+
assertThat(eqqqTransaction.getDateTime(), is(LocalDateTime.parse("2025-03-21T00:00")));
3652+
3653+
assertThat(eqqqTransaction.getCurrencyCode(), is("USD"));
3654+
assertThat(eqqqTransaction.getAmount(), is(1_36L)); // 1.36 USD
3655+
assertThat(eqqqTransaction.getSecurity().getCurrencyCode(), is("GBX"));
3656+
3657+
// Verify GROSS_VALUE unit exists and has correct conversion
3658+
// USD/EUR = 0.92464 (from fxRateToBase)
3659+
// GBP/EUR = 1.1726 (from ConversionRate on 20250321)
3660+
// USD/GBP = (USD/EUR) / (GBP/EUR) = 0.92464 / 1.1726 ≈ 0.7887
3661+
// GBX/GBP = 0.01 (from FixedExchangeRateProvider)
3662+
// USD/GBX = USD/GBP / GBX/GBP = 0.7887 / 0.01 = 78.87
3663+
// After inversion in setAmount: GBX/USD ≈ 0.01268
3664+
var eqqqGrossValueUnit = eqqqTransaction.getUnit(Unit.Type.GROSS_VALUE);
3665+
assertThat("GROSS_VALUE unit should be present for USD->GBX conversion", eqqqGrossValueUnit.isPresent(), is(true));
3666+
3667+
var eqqqUnit = eqqqGrossValueUnit.get();
3668+
assertThat(eqqqUnit.getAmount().getCurrencyCode(), is("USD"));
3669+
assertThat(eqqqUnit.getAmount().getAmount(), is(1_36L)); // 1.36 USD
3670+
assertThat(eqqqUnit.getForex().getCurrencyCode(), is("GBX"));
3671+
assertThat(eqqqUnit.getForex().getAmount(), is(107_24L)); // 107.24 GBX (rounded)
3672+
3673+
// Exchange rate calculation (with 10 decimal precision, HALF_DOWN in divisions):
3674+
// USD/EUR = 0.92464, GBP/EUR = 1.1726
3675+
// USD/GBP = 0.92464 / 1.1726 = 0.7887030844... (exact)
3676+
// With 10 decimals HALF_DOWN: 0.7887030844
3677+
// USD/GBX = 0.7887030844 / 0.01 = 78.87030844
3678+
// After inversion in setAmount (10 decimals, HALF_DOWN): GBX/USD = 1 / 78.87030844
3679+
// Calculate expected rate with same precision as implementation
3680+
BigDecimal usdToGbp = new BigDecimal("0.92464").divide(new BigDecimal("1.1726"), 10, RoundingMode.HALF_DOWN);
3681+
BigDecimal usdToGbx = usdToGbp.divide(new BigDecimal("0.01"), 10, RoundingMode.HALF_DOWN);
3682+
BigDecimal eqqqExpectedRate = BigDecimal.ONE.divide(usdToGbx, 10, RoundingMode.HALF_DOWN);
3683+
BigDecimal eqqqTolerance = new BigDecimal("0.0000001"); // Same tolerance as HIDR test
3684+
assertThat(eqqqUnit.getExchangeRate().subtract(eqqqExpectedRate).abs().compareTo(eqqqTolerance) < 0, is(true));
3685+
}
35613686
}
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: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,13 +935,78 @@ private BigDecimal getExchangeRate(Element element, String fromCurrency, String
935935
return asExchangeRate(element.getAttribute("fxRateToBase"));
936936
}
937937

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

9441003
BigDecimal fromRate = conversionRates.get(fromKey);
1004+
// If fromRate is not in conversionRates, try using fxRateToBase from the element
1005+
// (fxRateToBase is the rate from transaction currency to base currency)
1006+
if (fromRate == null && element.hasAttribute("fxRateToBase"))
1007+
{
1008+
fromRate = asExchangeRate(element.getAttribute("fxRateToBase"));
1009+
}
9451010
BigDecimal toRate = conversionRates.get(toKey);
9461011

9471012
if (fromRate != null && toRate != null)
@@ -956,6 +1021,29 @@ private BigDecimal getExchangeRate(Element element, String fromCurrency, String
9561021
return null;
9571022
}
9581023

1024+
/**
1025+
* Returns the major unit currency for a given minor unit currency, or null
1026+
* if the currency is not a known minor unit.
1027+
*
1028+
* @param minorUnitCurrency The minor unit currency (e.g., "GBX")
1029+
* @return The major unit currency (e.g., "GBP"), or null if not a known minor unit
1030+
*/
1031+
private String findMajorUnitCurrency(String minorUnitCurrency)
1032+
{
1033+
for (var series : FIXED_RATE_PROVIDER.getAvailableTimeSeries(null))
1034+
{
1035+
if (series.getRates() == null || series.getRates().isEmpty())
1036+
continue;
1037+
1038+
if (series.getBaseCurrency().equals(minorUnitCurrency) && series.getTermCurrency() != null)
1039+
{
1040+
return series.getTermCurrency();
1041+
}
1042+
}
1043+
1044+
return null;
1045+
}
1046+
9591047
/**
9601048
* Returns the exchange rate for currency pairs with a fixed
9611049
* relationship (e.g. GBX/GBP) using FixedExchangeRateProvider. Handles

0 commit comments

Comments
 (0)