Skip to content

Commit 473efe3

Browse files
Improve IBFlex cross-currency dividend import
Handle dividends whose payment currency differs from the security currency, including minor-unit currencies such as GBX. Add test coverage for USD->GBP, USD->GBX, and GBX->USD dividend imports across EUR- and GBP-based statements.
1 parent 9e795d5 commit 473efe3

3 files changed

Lines changed: 312 additions & 24 deletions

File tree

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

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
import java.io.FileOutputStream;
3838
import java.io.IOException;
3939
import java.io.InputStream;
40+
import java.math.BigDecimal;
41+
import java.math.RoundingMode;
4042
import java.nio.file.Files;
4143
import java.time.LocalDateTime;
4244
import java.util.ArrayList;
@@ -3675,4 +3677,197 @@ public void testParseDateTimeFormats()
36753677
// Invalid format should return null
36763678
assertThat(IBFlexStatementExtractor.parseDateTime("invalid"), is(nullValue()));
36773679
}
3680+
3681+
@Test
3682+
public void testIBFlexStatementFile29() throws IOException
3683+
{
3684+
// Test cross-rate calculation using fxRateToBase when direct rate is missing
3685+
// Case 1: Transaction currency: USD, Security currency: GBP, Base currency: EUR
3686+
// Case 2: Transaction currency: USD, Security currency: GBX (minor unit), Base currency: EUR
3687+
// Case 3: Transaction currency: USD, Security currency: GBX (minor unit), Base currency: GBP
3688+
// USD/EUR available via fxRateToBase, GBP/EUR available via ConversionRate
3689+
var client = new Client();
3690+
3691+
// Create security with GBP currency (security currency differs from transaction currency)
3692+
var hidr = new Security("HSBC MSCI INDONESIA UCITS ET", "GBP");
3693+
hidr.setTickerSymbol("HIDR");
3694+
hidr.setIsin("IE00B46G8275");
3695+
hidr.setWkn("86281326");
3696+
client.addSecurity(hidr);
3697+
3698+
// Create security with GBX currency (minor unit)
3699+
var eqqq = new Security("INVESCO NASDAQ-100 DIST", "GBX");
3700+
eqqq.setTickerSymbol("EQQQ");
3701+
eqqq.setIsin("IE0032077012");
3702+
eqqq.setWkn("18706552");
3703+
client.addSecurity(eqqq);
3704+
3705+
// Create security with USD currency for reverse minor-unit conversion
3706+
var gbdv = new Security("Global USD Dividend Fund", "USD");
3707+
gbdv.setTickerSymbol("GBDV");
3708+
gbdv.setIsin("US1234567890");
3709+
gbdv.setWkn("12345678");
3710+
client.addSecurity(gbdv);
3711+
3712+
var referenceAccount = new Account("A");
3713+
referenceAccount.setCurrencyCode("EUR");
3714+
client.addAccount(referenceAccount);
3715+
3716+
var extractor = new IBFlexStatementExtractor(client);
3717+
3718+
var activityStatement = getClass().getResourceAsStream("testIBFlexStatementFile29.xml");
3719+
var tempFile = createTempFile(activityStatement);
3720+
3721+
var errors = new ArrayList<Exception>();
3722+
3723+
var results = extractor.extract(Collections.singletonList(tempFile), errors);
3724+
assertThat(errors, empty());
3725+
assertThat(countSecurities(results), is(0L)); // Securities already exist
3726+
assertThat(countBuySell(results), is(0L));
3727+
assertThat(countAccountTransactions(results), is(4L));
3728+
3729+
// Find the first dividend transaction (HIDR - USD->GBP)
3730+
List<AccountTransaction> transactions = results.stream() //
3731+
.filter(TransactionItem.class::isInstance) //
3732+
.map(item -> (AccountTransaction) ((TransactionItem) item).getSubject()) //
3733+
.filter(t -> t.getType() == AccountTransaction.Type.DIVIDENDS) //
3734+
.toList();
3735+
3736+
assertThat(transactions.size(), is(4));
3737+
3738+
// Test first transaction: HIDR (USD->GBP)
3739+
AccountTransaction hidrTransaction = transactions.stream() //
3740+
.filter(t -> "HIDR".equals(t.getSecurity().getTickerSymbol())) //
3741+
.findFirst() //
3742+
.orElseThrow(() -> new AssertionError("HIDR dividend transaction not found"));
3743+
3744+
assertThat(hidrTransaction.getType(), is(AccountTransaction.Type.DIVIDENDS));
3745+
assertThat(hidrTransaction.getDateTime(), is(LocalDateTime.parse("2025-03-04T00:00")));
3746+
3747+
assertThat(hidrTransaction.getCurrencyCode(), is("USD"));
3748+
assertThat(hidrTransaction.getAmount(), is(18_15L)); // 18.15 USD
3749+
assertThat(hidrTransaction.getSecurity().getCurrencyCode(), is("GBP"));
3750+
3751+
// Verify GROSS_VALUE unit exists and has correct conversion
3752+
// USD/EUR = 0.94106 (from fxRateToBase)
3753+
// GBP/EUR = 1.2041 (from ConversionRate on 20250304)
3754+
// USD/GBP = (USD/EUR) / (GBP/EUR) = 0.94106 / 1.2041 ≈ 0.781546
3755+
// After inversion in setAmount: GBP/USD ≈ 1.2795
3756+
var hidrGrossValueUnit = hidrTransaction.getUnit(Unit.Type.GROSS_VALUE);
3757+
assertThat("GROSS_VALUE unit should be present for USD->GBP conversion", hidrGrossValueUnit.isPresent(), is(true));
3758+
3759+
var hidrUnit = hidrGrossValueUnit.get();
3760+
assertThat(hidrUnit.getAmount().getCurrencyCode(), is("USD"));
3761+
assertThat(hidrUnit.getAmount().getAmount(), is(18_15L)); // 18.15 USD
3762+
assertThat(hidrUnit.getForex().getCurrencyCode(), is("GBP"));
3763+
assertThat(hidrUnit.getForex().getAmount(), is(14_19L)); // 14.19 GBP (rounded)
3764+
3765+
BigDecimal hidrExpectedRate = new BigDecimal("1.2795145899");
3766+
BigDecimal tolerance = new BigDecimal("0.0000001");
3767+
assertThat(hidrUnit.getExchangeRate().subtract(hidrExpectedRate).abs().compareTo(tolerance) < 0, is(true));
3768+
3769+
// Test second transaction: EQQQ (USD->GBX via GBP)
3770+
AccountTransaction eqqqTransaction = transactions.stream() //
3771+
.filter(t -> "EQQQ".equals(t.getSecurity().getTickerSymbol())) //
3772+
.filter(t -> LocalDateTime.parse("2025-03-21T00:00").equals(t.getDateTime())) //
3773+
.findFirst() //
3774+
.orElseThrow(() -> new AssertionError("EQQQ dividend transaction not found"));
3775+
3776+
assertThat(eqqqTransaction.getType(), is(AccountTransaction.Type.DIVIDENDS));
3777+
assertThat(eqqqTransaction.getDateTime(), is(LocalDateTime.parse("2025-03-21T00:00")));
3778+
3779+
assertThat(eqqqTransaction.getCurrencyCode(), is("USD"));
3780+
assertThat(eqqqTransaction.getAmount(), is(1_36L)); // 1.36 USD
3781+
assertThat(eqqqTransaction.getSecurity().getCurrencyCode(), is("GBX"));
3782+
3783+
// Verify GROSS_VALUE unit exists and has correct conversion
3784+
// USD/EUR = 0.92464 (from fxRateToBase)
3785+
// GBP/EUR = 1.1726 (from ConversionRate on 20250321)
3786+
// USD/GBP = (USD/EUR) / (GBP/EUR) = 0.92464 / 1.1726 ≈ 0.7887
3787+
// GBX/GBP = 0.01 (from FixedExchangeRateProvider)
3788+
// USD/GBX = USD/GBP / GBX/GBP = 0.7887 / 0.01 = 78.87
3789+
// After inversion in setAmount: GBX/USD ≈ 0.01268
3790+
var eqqqGrossValueUnit = eqqqTransaction.getUnit(Unit.Type.GROSS_VALUE);
3791+
assertThat("GROSS_VALUE unit should be present for USD->GBX conversion", eqqqGrossValueUnit.isPresent(), is(true));
3792+
3793+
var eqqqUnit = eqqqGrossValueUnit.get();
3794+
assertThat(eqqqUnit.getAmount().getCurrencyCode(), is("USD"));
3795+
assertThat(eqqqUnit.getAmount().getAmount(), is(1_36L)); // 1.36 USD
3796+
assertThat(eqqqUnit.getForex().getCurrencyCode(), is("GBX"));
3797+
assertThat(eqqqUnit.getForex().getAmount(), is(107_24L)); // 107.24 GBX (rounded)
3798+
3799+
// Exchange rate calculation (with 10 decimal precision, HALF_DOWN in divisions):
3800+
// USD/EUR = 0.92464, GBP/EUR = 1.1726
3801+
// USD/GBP = 0.92464 / 1.1726 = 0.7887030844... (exact)
3802+
// With 10 decimals HALF_DOWN: 0.7887030844
3803+
// USD/GBX = 0.7887030844 / 0.01 = 78.87030844
3804+
// After inversion in setAmount (10 decimals, HALF_DOWN): GBX/USD = 1 / 78.87030844
3805+
// Calculate expected rate with same precision as implementation
3806+
BigDecimal usdToGbp = new BigDecimal("0.92464").divide(new BigDecimal("1.1726"), 10, RoundingMode.HALF_DOWN);
3807+
BigDecimal usdToGbx = usdToGbp.divide(new BigDecimal("0.01"), 10, RoundingMode.HALF_DOWN);
3808+
BigDecimal eqqqExpectedRate = BigDecimal.ONE.divide(usdToGbx, 10, RoundingMode.HALF_DOWN);
3809+
BigDecimal eqqqTolerance = new BigDecimal("0.0000001"); // Same tolerance as HIDR test
3810+
assertThat(eqqqUnit.getExchangeRate().subtract(eqqqExpectedRate).abs().compareTo(eqqqTolerance) < 0, is(true));
3811+
3812+
// Test third transaction: EQQQ (USD->GBX with GBP base currency)
3813+
AccountTransaction eqqqGbpBaseTransaction = transactions.stream() //
3814+
.filter(t -> "EQQQ".equals(t.getSecurity().getTickerSymbol())) //
3815+
.filter(t -> LocalDateTime.parse("2025-03-25T00:00").equals(t.getDateTime())) //
3816+
.findFirst() //
3817+
.orElseThrow(() -> new AssertionError("EQQQ GBP-base dividend transaction not found"));
3818+
3819+
assertThat(eqqqGbpBaseTransaction.getType(), is(AccountTransaction.Type.DIVIDENDS));
3820+
assertThat(eqqqGbpBaseTransaction.getDateTime(), is(LocalDateTime.parse("2025-03-25T00:00")));
3821+
3822+
assertThat(eqqqGbpBaseTransaction.getCurrencyCode(), is("USD"));
3823+
assertThat(eqqqGbpBaseTransaction.getAmount(), is(1_27L)); // 1.27 USD
3824+
assertThat(eqqqGbpBaseTransaction.getSecurity().getCurrencyCode(), is("GBX"));
3825+
3826+
// Base currency already matches the major unit (GBP), so fxRateToBase should still
3827+
// allow deriving USD->GBX via USD->GBP and GBP->GBX.
3828+
var eqqqGbpBaseGrossValueUnit = eqqqGbpBaseTransaction.getUnit(Unit.Type.GROSS_VALUE);
3829+
assertThat("GROSS_VALUE unit should be present for USD->GBX conversion with GBP base currency",
3830+
eqqqGbpBaseGrossValueUnit.isPresent(), is(true));
3831+
3832+
var eqqqGbpBaseUnit = eqqqGbpBaseGrossValueUnit.get();
3833+
assertThat(eqqqGbpBaseUnit.getAmount().getCurrencyCode(), is("USD"));
3834+
assertThat(eqqqGbpBaseUnit.getAmount().getAmount(), is(1_27L)); // 1.27 USD
3835+
assertThat(eqqqGbpBaseUnit.getForex().getCurrencyCode(), is("GBX"));
3836+
assertThat(eqqqGbpBaseUnit.getForex().getAmount(), is(98_62L)); // 98.62 GBX (rounded)
3837+
3838+
BigDecimal usdToGbxWithGbpBase = new BigDecimal("0.77654").divide(new BigDecimal("0.01"), 10, RoundingMode.HALF_DOWN);
3839+
BigDecimal eqqqGbpBaseExpectedRate = BigDecimal.ONE.divide(usdToGbxWithGbpBase, 10, RoundingMode.HALF_DOWN);
3840+
assertThat(eqqqGbpBaseUnit.getExchangeRate().subtract(eqqqGbpBaseExpectedRate).abs().compareTo(eqqqTolerance) < 0,
3841+
is(true));
3842+
3843+
// Test fourth transaction: GBDV (GBX->USD with GBP base currency)
3844+
AccountTransaction gbdvTransaction = transactions.stream() //
3845+
.filter(t -> "GBDV".equals(t.getSecurity().getTickerSymbol())) //
3846+
.filter(t -> LocalDateTime.parse("2025-03-26T00:00").equals(t.getDateTime())) //
3847+
.findFirst() //
3848+
.orElseThrow(() -> new AssertionError("GBDV GBP-base dividend transaction not found"));
3849+
3850+
assertThat(gbdvTransaction.getType(), is(AccountTransaction.Type.DIVIDENDS));
3851+
assertThat(gbdvTransaction.getDateTime(), is(LocalDateTime.parse("2025-03-26T00:00")));
3852+
3853+
assertThat(gbdvTransaction.getCurrencyCode(), is("GBX"));
3854+
assertThat(gbdvTransaction.getAmount(), is(138_00L)); // 138.00 GBX
3855+
assertThat(gbdvTransaction.getSecurity().getCurrencyCode(), is("USD"));
3856+
3857+
// Base currency already matches the major unit (GBP), so fxRateToBase should still
3858+
// allow deriving GBX->USD via GBX->GBP and GBP->USD.
3859+
var gbdvGrossValueUnit = gbdvTransaction.getUnit(Unit.Type.GROSS_VALUE);
3860+
assertThat("GROSS_VALUE unit should be present for GBX->USD conversion with GBP base currency",
3861+
gbdvGrossValueUnit.isPresent(), is(true));
3862+
3863+
var gbdvUnit = gbdvGrossValueUnit.get();
3864+
assertThat(gbdvUnit.getAmount().getCurrencyCode(), is("GBX"));
3865+
assertThat(gbdvUnit.getAmount().getAmount(), is(138_00L)); // 138.00 GBX
3866+
assertThat(gbdvUnit.getForex().getCurrencyCode(), is("USD"));
3867+
assertThat(gbdvUnit.getForex().getAmount(), is(1_79L)); // 1.79 USD (rounded)
3868+
3869+
BigDecimal gbxToUsd = new BigDecimal("0.01").multiply(BigDecimal.ONE.divide(new BigDecimal("0.7722"), 10, RoundingMode.HALF_DOWN));
3870+
BigDecimal gbdvExpectedRate = BigDecimal.ONE.divide(gbxToUsd, 10, RoundingMode.HALF_DOWN);
3871+
assertThat(gbdvUnit.getExchangeRate().subtract(gbdvExpectedRate).abs().compareTo(eqqqTolerance) < 0, is(true));
3872+
}
36783873
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<FlexQueryResponse queryName="PP" type="AF">
2+
<FlexStatements count="2">
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+
14+
</FlexStatement>
15+
<FlexStatement accountId="U1234567" fromDate="20250201" toDate="20250331" period="LastQuarter" whenGenerated="20250326;120000">
16+
<AccountInformation accountId="U1234567" acctAlias="B" model="" currency="GBP" name="John Doe" accountType="Individual" customerType="Individual" />
17+
<CashTransactions>
18+
<CashTransaction accountId="U1234567" acctAlias="" model="" currency="USD" fxRateToBase="0.77654" assetCategory="STK" subCategory="ETF" symbol="EQQQ" description="EQQQ(IE0032077012) CASH DIVIDEND USD 0.4233 PER SHARE (Mixed Income)" conid="18706552" securityID="IE0032077012" securityIDType="ISIN" isin="IE0032077012" amount="1.27" type="Dividends" reportDate="20250325" />
19+
<CashTransaction accountId="U1234567" acctAlias="" model="" currency="GBX" fxRateToBase="0.01" assetCategory="STK" symbol="GBDV" description="GBDV(US1234567890) CASH DIVIDEND GBX 0.5520 PER SHARE (Mixed Income)" conid="12345678" securityID="US1234567890" securityIDType="ISIN" isin="US1234567890" amount="138.00" type="Dividends" reportDate="20250326" />
20+
</CashTransactions>
21+
<ConversionRates>
22+
<ConversionRate reportDate="20250326" fromCurrency="USD" toCurrency="GBP" rate="0.7722" />
23+
</ConversionRates>
24+
</FlexStatement>
25+
</FlexStatements>
26+
</FlexQueryResponse>

0 commit comments

Comments
 (0)