Skip to content

Commit 099ba13

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). Made-with: Cursor
1 parent 66e0ad8 commit 099ba13

File tree

3 files changed

+222
-24
lines changed

3 files changed

+222
-24
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;
@@ -3632,4 +3634,127 @@ public void testParseDateTimeFormats()
36323634
// Invalid format should return null
36333635
assertThat(IBFlexStatementExtractor.parseDateTime("invalid"), is(nullValue()));
36343636
}
3637+
3638+
@Test
3639+
public void testIBFlexStatementFile28() throws IOException
3640+
{
3641+
// Test cross-rate calculation using fxRateToBase when direct rate is missing
3642+
// Case 1: Transaction currency: USD, Security currency: GBP, Base currency: EUR
3643+
// Case 2: Transaction currency: USD, Security currency: GBX (minor unit), Base currency: EUR
3644+
// USD/EUR available via fxRateToBase, GBP/EUR available via ConversionRate
3645+
var client = new Client();
3646+
3647+
// Create security with GBP currency (security currency differs from transaction currency)
3648+
var hidr = new Security("HSBC MSCI INDONESIA UCITS ET", "GBP");
3649+
hidr.setTickerSymbol("HIDR");
3650+
hidr.setIsin("IE00B46G8275");
3651+
hidr.setWkn("86281326");
3652+
client.addSecurity(hidr);
3653+
3654+
// Create security with GBX currency (minor unit)
3655+
var eqqq = new Security("INVESCO NASDAQ-100 DIST", "GBX");
3656+
eqqq.setTickerSymbol("EQQQ");
3657+
eqqq.setIsin("IE0032077012");
3658+
eqqq.setWkn("18706552");
3659+
client.addSecurity(eqqq);
3660+
3661+
var referenceAccount = new Account("A");
3662+
referenceAccount.setCurrencyCode("EUR");
3663+
client.addAccount(referenceAccount);
3664+
3665+
var extractor = new IBFlexStatementExtractor(client);
3666+
3667+
var activityStatement = getClass().getResourceAsStream("testIBFlexStatementFile28.xml");
3668+
var tempFile = createTempFile(activityStatement);
3669+
3670+
var errors = new ArrayList<Exception>();
3671+
3672+
var results = extractor.extract(Collections.singletonList(tempFile), errors);
3673+
assertThat(errors, empty());
3674+
assertThat(countSecurities(results), is(0L)); // Securities already exist
3675+
assertThat(countBuySell(results), is(0L));
3676+
assertThat(countAccountTransactions(results), is(2L));
3677+
3678+
// Find the first dividend transaction (HIDR - USD->GBP)
3679+
List<AccountTransaction> transactions = results.stream() //
3680+
.filter(TransactionItem.class::isInstance) //
3681+
.map(item -> (AccountTransaction) ((TransactionItem) item).getSubject()) //
3682+
.filter(t -> t.getType() == AccountTransaction.Type.DIVIDENDS) //
3683+
.toList();
3684+
3685+
assertThat(transactions.size(), is(2));
3686+
3687+
// Test first transaction: HIDR (USD->GBP)
3688+
AccountTransaction hidrTransaction = transactions.stream() //
3689+
.filter(t -> "HIDR".equals(t.getSecurity().getTickerSymbol())) //
3690+
.findFirst() //
3691+
.orElseThrow(() -> new AssertionError("HIDR dividend transaction not found"));
3692+
3693+
assertThat(hidrTransaction.getType(), is(AccountTransaction.Type.DIVIDENDS));
3694+
assertThat(hidrTransaction.getDateTime(), is(LocalDateTime.parse("2025-03-04T00:00")));
3695+
3696+
assertThat(hidrTransaction.getCurrencyCode(), is("USD"));
3697+
assertThat(hidrTransaction.getAmount(), is(18_15L)); // 18.15 USD
3698+
assertThat(hidrTransaction.getSecurity().getCurrencyCode(), is("GBP"));
3699+
3700+
// Verify GROSS_VALUE unit exists and has correct conversion
3701+
// USD/EUR = 0.94106 (from fxRateToBase)
3702+
// GBP/EUR = 1.2041 (from ConversionRate on 20250304)
3703+
// USD/GBP = (USD/EUR) / (GBP/EUR) = 0.94106 / 1.2041 ≈ 0.781546
3704+
// After inversion in setAmount: GBP/USD ≈ 1.2795
3705+
var hidrGrossValueUnit = hidrTransaction.getUnit(Unit.Type.GROSS_VALUE);
3706+
assertThat("GROSS_VALUE unit should be present for USD->GBP conversion", hidrGrossValueUnit.isPresent(), is(true));
3707+
3708+
var hidrUnit = hidrGrossValueUnit.get();
3709+
assertThat(hidrUnit.getAmount().getCurrencyCode(), is("USD"));
3710+
assertThat(hidrUnit.getAmount().getAmount(), is(18_15L)); // 18.15 USD
3711+
assertThat(hidrUnit.getForex().getCurrencyCode(), is("GBP"));
3712+
assertThat(hidrUnit.getForex().getAmount(), is(14_19L)); // 14.19 GBP (rounded)
3713+
3714+
BigDecimal hidrExpectedRate = new BigDecimal("1.2795145899");
3715+
BigDecimal tolerance = new BigDecimal("0.0000001");
3716+
assertThat(hidrUnit.getExchangeRate().subtract(hidrExpectedRate).abs().compareTo(tolerance) < 0, is(true));
3717+
3718+
// Test second transaction: EQQQ (USD->GBX via GBP)
3719+
AccountTransaction eqqqTransaction = transactions.stream() //
3720+
.filter(t -> "EQQQ".equals(t.getSecurity().getTickerSymbol())) //
3721+
.findFirst() //
3722+
.orElseThrow(() -> new AssertionError("EQQQ dividend transaction not found"));
3723+
3724+
assertThat(eqqqTransaction.getType(), is(AccountTransaction.Type.DIVIDENDS));
3725+
assertThat(eqqqTransaction.getDateTime(), is(LocalDateTime.parse("2025-03-21T00:00")));
3726+
3727+
assertThat(eqqqTransaction.getCurrencyCode(), is("USD"));
3728+
assertThat(eqqqTransaction.getAmount(), is(1_36L)); // 1.36 USD
3729+
assertThat(eqqqTransaction.getSecurity().getCurrencyCode(), is("GBX"));
3730+
3731+
// Verify GROSS_VALUE unit exists and has correct conversion
3732+
// USD/EUR = 0.92464 (from fxRateToBase)
3733+
// GBP/EUR = 1.1726 (from ConversionRate on 20250321)
3734+
// USD/GBP = (USD/EUR) / (GBP/EUR) = 0.92464 / 1.1726 ≈ 0.7887
3735+
// GBX/GBP = 0.01 (from FixedExchangeRateProvider)
3736+
// USD/GBX = USD/GBP / GBX/GBP = 0.7887 / 0.01 = 78.87
3737+
// After inversion in setAmount: GBX/USD ≈ 0.01268
3738+
var eqqqGrossValueUnit = eqqqTransaction.getUnit(Unit.Type.GROSS_VALUE);
3739+
assertThat("GROSS_VALUE unit should be present for USD->GBX conversion", eqqqGrossValueUnit.isPresent(), is(true));
3740+
3741+
var eqqqUnit = eqqqGrossValueUnit.get();
3742+
assertThat(eqqqUnit.getAmount().getCurrencyCode(), is("USD"));
3743+
assertThat(eqqqUnit.getAmount().getAmount(), is(1_36L)); // 1.36 USD
3744+
assertThat(eqqqUnit.getForex().getCurrencyCode(), is("GBX"));
3745+
assertThat(eqqqUnit.getForex().getAmount(), is(107_24L)); // 107.24 GBX (rounded)
3746+
3747+
// Exchange rate calculation (with 10 decimal precision, HALF_DOWN in divisions):
3748+
// USD/EUR = 0.92464, GBP/EUR = 1.1726
3749+
// USD/GBP = 0.92464 / 1.1726 = 0.7887030844... (exact)
3750+
// With 10 decimals HALF_DOWN: 0.7887030844
3751+
// USD/GBX = 0.7887030844 / 0.01 = 78.87030844
3752+
// After inversion in setAmount (10 decimals, HALF_DOWN): GBX/USD = 1 / 78.87030844
3753+
// Calculate expected rate with same precision as implementation
3754+
BigDecimal usdToGbp = new BigDecimal("0.92464").divide(new BigDecimal("1.1726"), 10, RoundingMode.HALF_DOWN);
3755+
BigDecimal usdToGbx = usdToGbp.divide(new BigDecimal("0.01"), 10, RoundingMode.HALF_DOWN);
3756+
BigDecimal eqqqExpectedRate = BigDecimal.ONE.divide(usdToGbx, 10, RoundingMode.HALF_DOWN);
3757+
BigDecimal eqqqTolerance = new BigDecimal("0.0000001"); // Same tolerance as HIDR test
3758+
assertThat(eqqqUnit.getExchangeRate().subtract(eqqqExpectedRate).abs().compareTo(eqqqTolerance) < 0, is(true));
3759+
}
36353760
}
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: 82 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@
6060
import name.abuchen.portfolio.money.ExchangeRate;
6161
import name.abuchen.portfolio.money.Money;
6262
import name.abuchen.portfolio.money.Values;
63-
import name.abuchen.portfolio.money.impl.FixedExchangeRateProvider;
6463
import name.abuchen.portfolio.online.QuoteFeed;
6564
import name.abuchen.portfolio.online.impl.YahooFinanceQuoteFeed;
6665
import name.abuchen.portfolio.util.Pair;
@@ -355,7 +354,12 @@ private class IBFlexStatementExtractorResult
355354
private static final String ASSETKEY_FUTURE_OPTION = "FOP";
356355
private static final String ASSETKEY_WARRANTS = "WAR";
357356

358-
private static final FixedExchangeRateProvider FIXED_RATE_PROVIDER = new FixedExchangeRateProvider();
357+
private static final Map<String, String> MINOR_TO_MAJOR_CURRENCY = Map.of(
358+
"GBX", "GBP",
359+
"ILA", "ILS",
360+
"ZAC", "ZAR"
361+
);
362+
private static final BigDecimal MINOR_UNIT_RATE = new BigDecimal("0.01");
359363

360364
private Element statement;
361365
private List<Exception> errors = new ArrayList<>();
@@ -982,13 +986,78 @@ private BigDecimal getExchangeRate(Element element, String fromCurrency, String
982986
return asExchangeRate(element.getAttribute("fxRateToBase"));
983987
}
984988

989+
// Before trying accountCurrency conversion, check if toCurrency is a minor unit
990+
// (e.g., GBX is minor unit of GBP). If so, convert to the major unit first.
991+
String toMajorUnit = findMajorUnitCurrency(toCurrency);
992+
if (toMajorUnit != null)
993+
{
994+
// Convert fromCurrency -> toMajorUnit via accountCurrency
995+
// Then apply minor unit conversion
996+
Pair<String, String> fromKey = new Pair<>(dateStr, fromCurrency + "-" + accountCurrency);
997+
Pair<String, String> toKey = new Pair<>(dateStr, toMajorUnit + "-" + accountCurrency);
998+
999+
BigDecimal fromRate = conversionRates.get(fromKey);
1000+
// If fromRate is not in conversionRates, try using fxRateToBase from the element
1001+
// (fxRateToBase is the rate from transaction currency to base currency)
1002+
if (fromRate == null && element.hasAttribute("fxRateToBase"))
1003+
{
1004+
fromRate = asExchangeRate(element.getAttribute("fxRateToBase"));
1005+
}
1006+
BigDecimal majorUnitRate = conversionRates.get(toKey);
1007+
1008+
if (fromRate != null && majorUnitRate != null)
1009+
{
1010+
// Calculate fromCurrency -> toMajorUnit
1011+
BigDecimal toMajorUnitRate = fromRate.divide(majorUnitRate, 10, RoundingMode.HALF_DOWN);
1012+
// Apply minor unit conversion: toMajorUnitRate / minorUnitRate
1013+
// (e.g., USD->GBP / GBX->GBP = USD->GBX)
1014+
BigDecimal minorUnitRate = getWellKnownFixedExchangeRate(toCurrency, toMajorUnit);
1015+
if (minorUnitRate != null)
1016+
{
1017+
return toMajorUnitRate.divide(minorUnitRate, 10, RoundingMode.HALF_DOWN);
1018+
}
1019+
}
1020+
}
1021+
1022+
// Check if fromCurrency is a minor unit (e.g., GBX -> USD)
1023+
String fromMajorUnit = findMajorUnitCurrency(fromCurrency);
1024+
if (fromMajorUnit != null)
1025+
{
1026+
// First convert fromCurrency (minor) -> fromMajorUnit (major)
1027+
// Then convert fromMajorUnit -> toCurrency via accountCurrency
1028+
BigDecimal minorToMajorRate = getWellKnownFixedExchangeRate(fromCurrency, fromMajorUnit);
1029+
if (minorToMajorRate != null)
1030+
{
1031+
Pair<String, String> fromKey = new Pair<>(dateStr, fromMajorUnit + "-" + accountCurrency);
1032+
Pair<String, String> toKey = new Pair<>(dateStr, toCurrency + "-" + accountCurrency);
1033+
1034+
BigDecimal fromMajorRate = conversionRates.get(fromKey);
1035+
BigDecimal toRate = conversionRates.get(toKey);
1036+
1037+
if (fromMajorRate != null && toRate != null)
1038+
{
1039+
// Calculate fromMajorUnit -> toCurrency
1040+
BigDecimal majorToTargetRate = fromMajorRate.divide(toRate, 10, RoundingMode.HALF_DOWN);
1041+
// Combine: fromCurrency -> fromMajorUnit -> toCurrency
1042+
// (e.g., GBX->GBP * GBP->USD = GBX->USD)
1043+
return minorToMajorRate.multiply(majorToTargetRate);
1044+
}
1045+
}
1046+
}
1047+
9851048
// Attempt to calculate cross rate via accountCurrency. No use
9861049
// in trying a different intermediate currency, it seems like
9871050
// toCurrency is only ever the account's base.
9881051
Pair<String, String> fromKey = new Pair<>(dateStr, fromCurrency + "-" + accountCurrency);
9891052
Pair<String, String> toKey = new Pair<>(dateStr, toCurrency + "-" + accountCurrency);
9901053

9911054
BigDecimal fromRate = conversionRates.get(fromKey);
1055+
// If fromRate is not in conversionRates, try using fxRateToBase from the element
1056+
// (fxRateToBase is the rate from transaction currency to base currency)
1057+
if (fromRate == null && element.hasAttribute("fxRateToBase"))
1058+
{
1059+
fromRate = asExchangeRate(element.getAttribute("fxRateToBase"));
1060+
}
9921061
BigDecimal toRate = conversionRates.get(toKey);
9931062

9941063
if (fromRate != null && toRate != null)
@@ -1003,31 +1072,20 @@ private BigDecimal getExchangeRate(Element element, String fromCurrency, String
10031072
return null;
10041073
}
10051074

1006-
/**
1007-
* Returns the exchange rate for currency pairs with a fixed
1008-
* relationship (e.g. GBX/GBP) using FixedExchangeRateProvider. Handles
1009-
* both directions.
1010-
*
1011-
* @param fromCurrency
1012-
* The source currency
1013-
* @param toCurrency
1014-
* The target currency
1015-
* @return The exchange rate, or null if not a known fixed-rate pair
1016-
*/
1017-
private BigDecimal getWellKnownFixedExchangeRate(String fromCurrency, String toCurrency)
1075+
private String findMajorUnitCurrency(String minorUnitCurrency)
10181076
{
1019-
for (var series : FIXED_RATE_PROVIDER.getAvailableTimeSeries(null))
1020-
{
1021-
if (series.getRates() == null || series.getRates().isEmpty())
1022-
continue;
1077+
return MINOR_TO_MAJOR_CURRENCY.get(minorUnitCurrency);
1078+
}
10231079

1024-
var rate = series.getRates().get(0).getValue();
1080+
private BigDecimal getWellKnownFixedExchangeRate(String fromCurrency, String toCurrency)
1081+
{
1082+
var majorUnit = MINOR_TO_MAJOR_CURRENCY.get(fromCurrency);
1083+
if (majorUnit != null && majorUnit.equals(toCurrency))
1084+
return MINOR_UNIT_RATE;
10251085

1026-
if (series.getBaseCurrency().equals(fromCurrency) && series.getTermCurrency().equals(toCurrency))
1027-
return rate;
1028-
else if (series.getBaseCurrency().equals(toCurrency) && series.getTermCurrency().equals(fromCurrency))
1029-
return BigDecimal.ONE.divide(rate, 10, RoundingMode.HALF_UP);
1030-
}
1086+
majorUnit = MINOR_TO_MAJOR_CURRENCY.get(toCurrency);
1087+
if (majorUnit != null && majorUnit.equals(fromCurrency))
1088+
return BigDecimal.ONE.divide(MINOR_UNIT_RATE, 10, RoundingMode.HALF_UP);
10311089

10321090
return null;
10331091
}

0 commit comments

Comments
 (0)