From 0c0d28c54b9365a56aa774b4383c2db6e26d44b7 Mon Sep 17 00:00:00 2001 From: csprunk <3308269+csprunk@users.noreply.github.com> Date: Sat, 9 Apr 2022 15:14:34 -0700 Subject: [PATCH] Add gross value in security currency for some IBFlex transactions Issue: #2805 Signed-off-by: csprunk <3308269+csprunk@users.noreply.github.com> [squashed commits; rebased to master] Signed-off-by: Andreas Buchen --- ...tementWithForeignDividendNoAccountInfo.xml | 14 ++ ...rWithForeignDividendNoAccountInfoTest.java | 161 ++++++++++++++++++ .../IBFlexStatementExtractor.java | 42 ++++- 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/IBActivityStatementWithForeignDividendNoAccountInfo.xml create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/IBFlexStatementExtractorWithForeignDividendNoAccountInfoTest.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/IBActivityStatementWithForeignDividendNoAccountInfo.xml b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/IBActivityStatementWithForeignDividendNoAccountInfo.xml new file mode 100644 index 0000000000..e9a86ca359 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/IBActivityStatementWithForeignDividendNoAccountInfo.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/IBFlexStatementExtractorWithForeignDividendNoAccountInfoTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/IBFlexStatementExtractorWithForeignDividendNoAccountInfoTest.java new file mode 100644 index 0000000000..fd7b13e199 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/datatransfer/IBFlexStatementExtractorWithForeignDividendNoAccountInfoTest.java @@ -0,0 +1,161 @@ +package name.abuchen.portfolio.datatransfer; + +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.apache.pdfbox.io.IOUtils; +import org.junit.Test; + +import name.abuchen.portfolio.datatransfer.Extractor.BuySellEntryItem; +import name.abuchen.portfolio.datatransfer.Extractor.Item; +import name.abuchen.portfolio.datatransfer.Extractor.SecurityItem; +import name.abuchen.portfolio.datatransfer.Extractor.TransactionItem; +import name.abuchen.portfolio.model.AccountTransaction; +import name.abuchen.portfolio.model.BuySellEntry; +import name.abuchen.portfolio.model.Client; +import name.abuchen.portfolio.model.PortfolioTransaction; +import name.abuchen.portfolio.model.Security; +import name.abuchen.portfolio.model.Transaction.Unit; +import name.abuchen.portfolio.money.CurrencyUnit; +import name.abuchen.portfolio.money.Money; +import name.abuchen.portfolio.money.Quote; +import name.abuchen.portfolio.money.Values; + +@SuppressWarnings("nls") +public class IBFlexStatementExtractorWithForeignDividendNoAccountInfoTest +{ + + private List runExtractor(List errors) throws IOException + { + Client client = new Client(); + // We add two securities to the client with EUR as currency, both will + // receive dividends in USD. + Security security = new Security("3M CO. already defined", CurrencyUnit.EUR); + security.setIsin("US88579Y1010"); + client.addSecurity(security); + + security = new Security("CDW CORP/DE already defined", CurrencyUnit.EUR); + security.setIsin("US12514G1085"); + client.addSecurity(security); + + InputStream activityStatement = getClass() + .getResourceAsStream("IBActivityStatementWithForeignDividendNoAccountInfo.xml"); + Extractor.InputFile tempFile = createTempFile(activityStatement); + IBFlexStatementExtractor extractor = new IBFlexStatementExtractor(client); + + return extractor.extract(Collections.singletonList(tempFile), errors); + } + + @Test + public void testIBAcitvityStatement() throws IOException + { + List errors = new ArrayList<>(); + List results = runExtractor(errors); + assertThat(errors.isEmpty(), is(true)); + int numSecurity = 0; // The two securities are already present in the + // client. + int numBuySell = 2; + int numTransactions = 2; + + results.stream().filter(i -> !(i instanceof SecurityItem)) + .forEach(i -> assertThat(i.getAmount(), notNullValue())); + + List securityItems = results.stream().filter(SecurityItem.class::isInstance) + .collect(Collectors.toList()); + + assertThat(securityItems.size(), is(numSecurity)); + + List buySellTransactions = results.stream().filter(BuySellEntryItem.class::isInstance) + .collect(Collectors.toList()); + + assertThat(buySellTransactions.size(), is(numBuySell)); + + List accountTransactions = results.stream().filter(TransactionItem.class::isInstance) + .collect(Collectors.toList()); + + assertThat(accountTransactions.size(), is(numTransactions)); + + assertThat(results.size(), is(numSecurity + numBuySell + numTransactions)); + + assertFirstBuySell(results.stream().filter(BuySellEntryItem.class::isInstance).findFirst()); + assertFirstTransaction(results.stream().filter(TransactionItem.class::isInstance).findFirst()); + assertSecondTransaction(results.stream().filter(TransactionItem.class::isInstance).skip(1).findFirst()); + } + + private void assertFirstBuySell(Optional item) + { + assertThat(item.isPresent(), is(true)); + assertThat(item.orElseThrow().getSubject(), instanceOf(BuySellEntry.class)); + BuySellEntry entry = (BuySellEntry) item.orElseThrow().getSubject(); + + assertThat(entry.getPortfolioTransaction().getType(), is(PortfolioTransaction.Type.BUY)); + assertThat(entry.getAccountTransaction().getType(), is(AccountTransaction.Type.BUY)); + + assertThat(entry.getPortfolioTransaction().getSecurity().getName(), is("3M CO. already defined")); + assertThat(entry.getPortfolioTransaction().getMonetaryAmount(), is(Money.of("EUR", 1275_25L))); + assertThat(entry.getPortfolioTransaction().getDateTime(), is(LocalDateTime.parse("2018-02-09T11:19"))); + assertThat(entry.getPortfolioTransaction().getShares(), is(Values.Share.factorize(7))); + assertThat(entry.getPortfolioTransaction().getUnitSum(Unit.Type.FEE), is(Money.of("EUR", 5_80L))); + + assertThat(entry.getPortfolioTransaction().getGrossPricePerShare(), + is(Quote.of("EUR", Values.Quote.factorize(181.35)))); + } + + private void assertFirstTransaction(Optional item) + { + assertThat(item.isPresent(), is(true)); + assertThat(item.orElseThrow().getSubject(), instanceOf(AccountTransaction.class)); + AccountTransaction entry = (AccountTransaction) item.orElseThrow().getSubject(); + + assertThat(entry.getType(), is(AccountTransaction.Type.DIVIDENDS)); + + assertThat(entry.getSecurity().getName(), is("3M CO. already defined")); + assertThat(entry.getSecurity().getIsin(), is("US88579Y1010")); + assertThat(entry.getMonetaryAmount(), is(Money.of("USD", 9_52L))); + assertThat(entry.getCurrencyCode(), is("USD")); + assertThat(entry.getSecurity().getCurrencyCode(), is("EUR")); + Unit grossValue = entry.getUnit(Unit.Type.GROSS_VALUE).orElseThrow(); + assertThat(grossValue.getForex(), is(Money.of("EUR", 7_74L))); + assertThat(grossValue.getAmount(), is(Money.of("USD", 9_52L))); + } + + private void assertSecondTransaction(Optional item) + { + assertThat(item.isPresent(), is(true)); + assertThat(item.orElseThrow().getSubject(), instanceOf(AccountTransaction.class)); + AccountTransaction entry = (AccountTransaction) item.orElseThrow().getSubject(); + + assertThat(entry.getType(), is(AccountTransaction.Type.DIVIDENDS)); + + assertThat(entry.getSecurity().getName(), is("CDW CORP/DE already defined")); + assertThat(entry.getSecurity().getIsin(), is("US12514G1085")); + assertThat(entry.getMonetaryAmount(), is(Money.of("USD", 9_50L))); + assertThat(entry.getCurrencyCode(), is("USD")); + assertThat(entry.getSecurity().getCurrencyCode(), is("EUR")); + Unit grossValue = entry.getUnit(Unit.Type.GROSS_VALUE).orElseThrow(); + assertThat(grossValue.getForex(), is(Money.of("EUR", 8_04L))); + assertThat(grossValue.getAmount(), is(Money.of("USD", 9_50L))); + } + + private Extractor.InputFile createTempFile(InputStream input) throws IOException + { + File tempFile = File.createTempFile("iBFlexStatementExtractorTest", null); + FileOutputStream fos = new FileOutputStream(tempFile); + + IOUtils.copy(input, fos); + return new Extractor.InputFile(tempFile); + } +} diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/IBFlexStatementExtractor.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/IBFlexStatementExtractor.java index 433d7acba9..1b3b67ac46 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/IBFlexStatementExtractor.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/IBFlexStatementExtractor.java @@ -512,10 +512,9 @@ private void setAmount(Element element, Transaction transaction, Double amount, { fxRateToBase = new BigDecimal(1); } - BigDecimal inverseRate = BigDecimal.ONE.divide(fxRateToBase, 10, RoundingMode.HALF_DOWN); BigDecimal baseCurrencyMoney = BigDecimal.valueOf(amount.doubleValue() * Values.Amount.factor()) - .divide(inverseRate, RoundingMode.HALF_DOWN); + .multiply(fxRateToBase); transaction.setAmount(Math.round(baseCurrencyMoney.doubleValue())); transaction.setCurrencyCode(ibAccountCurrency); if (addUnit) @@ -532,6 +531,45 @@ private void setAmount(Element element, Transaction transaction, Double amount, { transaction.setAmount(Math.round(amount.doubleValue() * Values.Amount.factor())); transaction.setCurrencyCode(currency); + + if (addUnit && transaction.getSecurity() != null + && !transaction.getSecurity().getCurrencyCode().equals(currency)) + { + // If the transaction currency is different from the + // security currency (as stored in PP) we need to supply the + // gross value in the security currency. We assume that the + // security currency is the same that IB thinks of as base + // currency for this transaction (fxRateToBase). + String fxRateToBaseString = element.getAttribute("fxRateToBase"); + BigDecimal fxRateToBase; + if (fxRateToBaseString != null && !fxRateToBaseString.isEmpty()) + { + fxRateToBase = BigDecimal.valueOf(Double.parseDouble(fxRateToBaseString)); + } + else + { + fxRateToBase = new BigDecimal(1); + } + // To back out the amount in the security currency we could + // multiply with fxRateToBase. Instead, we calculate the + // inverse rate and divide by it as we need to supply the + // inverse rate for the gross value below (which converts + // from security currency to original + // transaction currency). + BigDecimal inverseRate = BigDecimal.ONE.divide(fxRateToBase, 10, RoundingMode.HALF_DOWN); + + BigDecimal securityCurrencyMoney = BigDecimal.valueOf(amount.doubleValue() * Values.Amount.factor()) + .divide(inverseRate, RoundingMode.HALF_DOWN); + + // Gross value with conversion information for the security + // currency. + Unit grossValue = new Unit(Unit.Type.GROSS_VALUE, transaction.getMonetaryAmount(), + Money.of(transaction.getSecurity().getCurrencyCode(), + Math.round(securityCurrencyMoney.doubleValue())), + inverseRate); + transaction.addUnit(grossValue); + } + } }