Skip to content

Commit 8c2c49c

Browse files
Improvement: IBFlex handle currency unit mismatches
Extend IBFlex importer to handle cases where the reported unit is not the same as the unit the exchange quotes in (e.g. GBP vs GBX)
1 parent 593f1cc commit 8c2c49c

File tree

3 files changed

+122
-4
lines changed

3 files changed

+122
-4
lines changed

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

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3486,4 +3486,72 @@ public void testIBFlexStatementFile26() throws IOException
34863486
hasAmount("EUR", 62.05), //
34873487
hasTaxes("EUR", 0.00), hasFees("EUR", 0.00)))));
34883488
}
3489+
3490+
@Test
3491+
public void testIBFlexStatementFile27() throws IOException
3492+
{
3493+
// Test minor unit currency handling: IBKR provides GBP, security is GBX
3494+
var client = new Client();
3495+
3496+
// Create security with GBX currency (minor unit)
3497+
var eqqq = new Security("INVESCO NASDAQ-100 DIST", "GBX");
3498+
eqqq.setTickerSymbol("EQQQ.L");
3499+
eqqq.setIsin("IE0032077012");
3500+
eqqq.setWkn("35628280");
3501+
client.addSecurity(eqqq);
3502+
3503+
var referenceAccount = new Account("A");
3504+
referenceAccount.setCurrencyCode("GBP");
3505+
client.addAccount(referenceAccount);
3506+
3507+
var portfolio = new Portfolio("U1234567");
3508+
portfolio.setReferenceAccount(referenceAccount);
3509+
client.addPortfolio(portfolio);
3510+
3511+
var extractor = new IBFlexStatementExtractor(client);
3512+
3513+
var activityStatement = getClass().getResourceAsStream("testIBFlexStatementFile27.xml");
3514+
var tempFile = createTempFile(activityStatement);
3515+
3516+
var errors = new ArrayList<Exception>();
3517+
3518+
var results = extractor.extract(Collections.singletonList(tempFile), errors);
3519+
assertThat(errors, empty());
3520+
assertThat(countSecurities(results), is(0L)); // Security already exists
3521+
assertThat(countBuySell(results), is(1L));
3522+
assertThat(countAccountTransactions(results), is(0L));
3523+
3524+
// Find the buy transaction
3525+
BuySellEntry entry = (BuySellEntry) results.stream() //
3526+
.filter(BuySellEntryItem.class::isInstance) //
3527+
.findFirst() //
3528+
.orElseThrow(() -> new AssertionError("Buy transaction not found")) //
3529+
.getSubject();
3530+
3531+
// Verify transaction currency is GBP (from IBKR)
3532+
assertThat(entry.getPortfolioTransaction().getCurrencyCode(), is("GBP"));
3533+
// netCash is -1369.52, but transaction amount should be absolute value
3534+
assertThat(entry.getPortfolioTransaction().getAmount(), is(136952L)); // 1369.52 GBP
3535+
3536+
// Verify security currency is GBX
3537+
assertThat(entry.getPortfolioTransaction().getSecurity().getCurrencyCode(), is("GBX"));
3538+
3539+
// Verify GROSS_VALUE unit exists and has correct conversion
3540+
var grossValueUnit = entry.getPortfolioTransaction().getUnit(Unit.Type.GROSS_VALUE);
3541+
assertThat("GROSS_VALUE unit should be present for GBP->GBX conversion", grossValueUnit.isPresent(), is(true));
3542+
3543+
var unit = grossValueUnit.get();
3544+
// GROSS_VALUE unit amount is the gross value before fees
3545+
// Transaction amount is 1369.52 GBP (netCash), fees are 3.00 GBP
3546+
// Gross value = 1369.52 - 3.00 = 1366.52 GBP = 136652 (in smallest unit)
3547+
assertThat(unit.getAmount().getCurrencyCode(), is("GBP"));
3548+
assertThat(unit.getAmount().getAmount(), is(136652L));
3549+
3550+
// Forex amount in GBX: 1369.52 GBP * 100 = 136952 GBX (in smallest unit)
3551+
assertThat(unit.getForex().getCurrencyCode(), is("GBX"));
3552+
assertThat(unit.getForex().getAmount(), is(13695200L)); // 136952.00 GBX
3553+
3554+
// Exchange rate should be 0.01 (GBP to GBX: 1 GBP = 100 GBX, so rate is 0.01)
3555+
assertThat(unit.getExchangeRate().compareTo(new java.math.BigDecimal("0.01")), is(0));
3556+
}
34893557
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<FlexQueryResponse queryName="PP" type="AF">
2+
<FlexStatements count="1">
3+
<FlexStatement accountId="U1234567" fromDate="20251201" toDate="20251231" period="LastMonth" whenGenerated="20251220;120000">
4+
<AccountInformation accountId="U1234567" acctAlias="A" model="" currency="GBP" name="John Doe" accountType="Individual" customerType="Individual" />
5+
<Trades>
6+
<Trade accountId="U1234567" acctAlias="" model="" currency="GBP" fxRateToBase="1.1426" assetCategory="STK" subCategory="ETF" symbol="EQQQ.L" description="INVESCO NASDAQ-100 DIST" conid="35628280" securityID="IE0032077012" securityIDType="ISIN" cusip="" isin="IE0032077012" figi="BBG000QYQVV5" listingExchange="LSEETF" underlyingConid="" underlyingSymbol="EQQQ.L" underlyingSecurityID="" underlyingListingExchange="" issuer="" issuerCountryCode="IE" tradeID="1252050191" multiplier="1" relatedTradeID="" strike="" reportDate="20251216" expiry="" dateTime="20251216;103059" putCall="" tradeDate="20251216" principalAdjustFactor="" settleDateTarget="20251218" transactionType="ExchTrade" exchange="TRWBUKETF" quantity="3" tradePrice="455.5054" tradeMoney="1366.52" proceeds="-1366.52" taxes="0" ibCommission="-3" ibCommissionCurrency="GBP" netCash="-1369.52" closePrice="455.49" openCloseIndicator="O" notes="" cost="1369.52" fifoPnlRealized="0" mtmPnl="-0.05" origTradePrice="0" origTradeDate="" origTradeID="" origOrderID="0" origTransactionID="0" buySell="BUY" clearingFirmID="" ibOrderID="1022115019" transactionID="5103375270" ibExecID="00015269.69411ea0.01.01" relatedTransactionID="" rtn="" brokerageOrderID="000e3ea6.0001cbfa.6940ef59.0001" orderReference="" volatilityOrderLink="" exchOrderId="N/A" extExecID="PC16JANEETF201531" orderTime="20251216;103058" openDateTime="" holdingPeriodDateTime="" whenRealized="" whenReopened="" levelOfDetail="EXECUTION" changeInPrice="0" changeInQuantity="0" orderType="LMT" traderID="" isAPIOrder="N" accruedInt="0" initialInvestment="" positionActionID="" serialNumber="" deliveryType="" commodityType="" fineness="0.0" weight="0.0" />
7+
</Trades>
8+
</FlexStatement>
9+
</FlexStatements>
10+
</FlexQueryResponse>
11+

name.abuchen.portfolio/src/name/abuchen/portfolio/datatransfer/ibflex/IBFlexStatementExtractor.java

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
import name.abuchen.portfolio.money.ExchangeRate;
5959
import name.abuchen.portfolio.money.Money;
6060
import name.abuchen.portfolio.money.Values;
61+
import name.abuchen.portfolio.money.impl.FixedExchangeRateProvider;
6162
import name.abuchen.portfolio.online.QuoteFeed;
6263
import name.abuchen.portfolio.online.impl.YahooFinanceQuoteFeed;
6364
import name.abuchen.portfolio.util.Pair;
@@ -611,6 +612,9 @@ private class IBFlexStatementExtractorResult
611612

612613
portfolioTransaction.setDate(extractDate(element));
613614

615+
// Set security before amount so that setAmount can detect currency mismatches in all cases
616+
portfolioTransaction.setSecurity(this.getOrCreateSecurity(element, true));
617+
614618
// @formatter:off
615619
// Set amount and check if the element contains the "netCash"
616620
// attribute. If the element contains only the "cost" attribute, the
@@ -621,14 +625,14 @@ private class IBFlexStatementExtractorResult
621625
Money amount = Money.of(asCurrencyCode(element.getAttribute("currency")), asAmount(element.getAttribute("netCash")));
622626

623627
setAmount(element, portfolioTransaction.getPortfolioTransaction(), amount);
624-
setAmount(element, portfolioTransaction.getAccountTransaction(), amount);
628+
portfolioTransaction.getAccountTransaction().setMonetaryAmount(amount);
625629
}
626630
else
627631
{
628632
Money amount = Money.of(asCurrencyCode(element.getAttribute("currency")), asAmount(element.getAttribute("cost")));
629633

630634
setAmount(element, portfolioTransaction.getPortfolioTransaction(), amount);
631-
setAmount(element, portfolioTransaction.getAccountTransaction(), amount);
635+
portfolioTransaction.getAccountTransaction().setMonetaryAmount(amount);
632636
}
633637

634638
// Set share quantity
@@ -646,8 +650,6 @@ private class IBFlexStatementExtractorResult
646650
Unit taxUnit = new Unit(Unit.Type.TAX, taxes);
647651
portfolioTransaction.getPortfolioTransaction().addUnit(taxUnit);
648652

649-
portfolioTransaction.setSecurity(this.getOrCreateSecurity(element, true));
650-
651653
// Set note
652654
if (portfolioTransaction.getNote() == null || !portfolioTransaction.getNote().equals(Messages.MsgErrorOrderCancellationUnsupported))
653655
{
@@ -938,6 +940,43 @@ private BigDecimal getExchangeRate(Element element, String fromCurrency, String
938940
return fromRate.divide(toRate, 10, RoundingMode.HALF_DOWN);
939941
}
940942

943+
// Check if there's a fixed exchange rate (e.g. GBP/GBX)
944+
BigDecimal fixedRate = getUnitExchangeRate(fromCurrency, toCurrency);
945+
if (fixedRate != null)
946+
return fixedRate;
947+
948+
return null;
949+
}
950+
951+
/**
952+
* Returns the exchange rate for currency pairs with a fixed relationship
953+
* (e.g. GBX/GBP) using FixedExchangeRateProvider. Handles both directions.
954+
*
955+
* @param fromCurrency The source currency
956+
* @param toCurrency The target currency
957+
* @return The exchange rate, or null if not a known fixed-rate pair
958+
*/
959+
private BigDecimal getUnitExchangeRate(String fromCurrency, String toCurrency)
960+
{
961+
var provider = new FixedExchangeRateProvider();
962+
963+
for (var series : provider.getAvailableTimeSeries(null))
964+
{
965+
if (series.getRates().isEmpty())
966+
continue;
967+
968+
BigDecimal rate = series.getRates().get(0).getValue();
969+
970+
if (series.getBaseCurrency().equals(fromCurrency) && series.getTermCurrency().equals(toCurrency))
971+
{
972+
return rate;
973+
}
974+
else if (series.getBaseCurrency().equals(toCurrency) && series.getTermCurrency().equals(fromCurrency))
975+
{
976+
return BigDecimal.ONE.divide(rate, 10, RoundingMode.HALF_UP);
977+
}
978+
}
979+
941980
return null;
942981
}
943982

0 commit comments

Comments
 (0)