|
37 | 37 | import java.io.FileOutputStream; |
38 | 38 | import java.io.IOException; |
39 | 39 | import java.io.InputStream; |
| 40 | +import java.math.BigDecimal; |
| 41 | +import java.math.RoundingMode; |
40 | 42 | import java.nio.file.Files; |
41 | 43 | import java.time.LocalDateTime; |
42 | 44 | import java.util.ArrayList; |
@@ -3675,4 +3677,197 @@ public void testParseDateTimeFormats() |
3675 | 3677 | // Invalid format should return null |
3676 | 3678 | assertThat(IBFlexStatementExtractor.parseDateTime("invalid"), is(nullValue())); |
3677 | 3679 | } |
| 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 | + } |
3678 | 3873 | } |
0 commit comments