|
36 | 36 | import java.io.FileOutputStream; |
37 | 37 | import java.io.IOException; |
38 | 38 | import java.io.InputStream; |
| 39 | +import java.math.BigDecimal; |
| 40 | +import java.math.RoundingMode; |
39 | 41 | import java.nio.file.Files; |
40 | 42 | import java.time.LocalDateTime; |
41 | 43 | import java.util.ArrayList; |
@@ -3632,4 +3634,127 @@ public void testParseDateTimeFormats() |
3632 | 3634 | // Invalid format should return null |
3633 | 3635 | assertThat(IBFlexStatementExtractor.parseDateTime("invalid"), is(nullValue())); |
3634 | 3636 | } |
| 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 | + } |
3635 | 3760 | } |
0 commit comments