package name.abuchen.portfolio.ui.dialogs.transactions; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; import org.eclipse.core.databinding.validation.ValidationStatus; import org.eclipse.core.runtime.IStatus; import com.ibm.icu.text.MessageFormat; import name.abuchen.portfolio.model.Client; import name.abuchen.portfolio.model.Portfolio; import name.abuchen.portfolio.model.PortfolioTransaction; import name.abuchen.portfolio.model.PortfolioTransaction.Type; import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.model.Transaction; import name.abuchen.portfolio.money.CurrencyConverter; import name.abuchen.portfolio.money.CurrencyConverterImpl; import name.abuchen.portfolio.money.CurrencyUnit; import name.abuchen.portfolio.money.ExchangeRate; import name.abuchen.portfolio.money.ExchangeRateTimeSeries; import name.abuchen.portfolio.money.Money; import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.snapshot.PortfolioSnapshot; import name.abuchen.portfolio.snapshot.SecurityPosition; import name.abuchen.portfolio.ui.Messages; public abstract class AbstractSecurityTransactionModel extends AbstractModel { public enum Properties { portfolio, security, account, date, shares, quote, grossValue, exchangeRate, inverseExchangeRate, // convertedGrossValue, forexFees, fees, forexTaxes, taxes, total, note, exchangeRateCurrencies, // inverseExchangeRateCurrencies, transactionCurrency, transactionCurrencyCode, securityCurrencyCode, // calculationStatus; } protected final Client client; protected PortfolioTransaction.Type type; protected Portfolio portfolio; protected Security security; protected LocalDate date = LocalDate.now(); protected long shares; protected BigDecimal quote = BigDecimal.ONE; protected long grossValue; protected BigDecimal exchangeRate = BigDecimal.ONE; protected long convertedGrossValue; protected long forexFees; protected long fees; protected long forexTaxes; protected long taxes; protected long total; protected String note; private IStatus calculationStatus = ValidationStatus.ok(); public AbstractSecurityTransactionModel(Client client, Type type) { this.client = client; this.type = type; } @Override public String getHeading() { return type.toString(); } public abstract boolean accepts(Type type); public abstract void setSource(Object source); @Override public void resetToNewTransaction() { setShares(0); setGrossValue(0); setConvertedGrossValue(0); setTotal(0); setFees(0); setTaxes(0); setForexFees(0); setForexTaxes(0); setNote(null); } protected void fillFromTransaction(PortfolioTransaction transaction) { this.security = transaction.getSecurity(); this.date = transaction.getDate(); this.shares = transaction.getShares(); this.total = transaction.getAmount(); this.note = transaction.getNote(); // will be overwritten if forex data exists this.exchangeRate = BigDecimal.ONE; transaction.getUnits().forEach(unit -> { switch (unit.getType()) { case GROSS_VALUE: this.exchangeRate = unit.getExchangeRate(); this.grossValue = unit.getForex().getAmount(); this.quote = new BigDecimal( this.grossValue * Values.Share.factor() / (this.shares * Values.Amount.divider())); break; case FEE: if (unit.getForex() != null) this.forexFees += unit.getForex().getAmount(); else this.fees += unit.getAmount().getAmount(); break; case TAX: if (unit.getForex() != null) this.forexTaxes += unit.getForex().getAmount(); else this.taxes += unit.getAmount().getAmount(); break; default: throw new UnsupportedOperationException(); } }); this.convertedGrossValue = calculateConvertedGrossValue(); if (exchangeRate.equals(BigDecimal.ONE)) { // units contained no information about forex this.grossValue = convertedGrossValue; this.quote = transaction.getGrossPricePerShare().toBigDecimal(); } firePropertyChange(Properties.calculationStatus.name(), this.calculationStatus, this.calculationStatus = calculateStatus()); } protected void writeToTransaction(PortfolioTransaction transaction) { transaction.clearUnits(); if (fees != 0) transaction.addUnit(new Transaction.Unit(Transaction.Unit.Type.FEE, // Money.of(getTransactionCurrencyCode(), fees))); if (taxes != 0) transaction.addUnit(new Transaction.Unit(Transaction.Unit.Type.TAX, // Money.of(getTransactionCurrencyCode(), taxes))); boolean hasForex = !getTransactionCurrencyCode().equals(getSecurityCurrencyCode()); if (hasForex) { if (forexFees != 0) transaction.addUnit(new Transaction.Unit(Transaction.Unit.Type.FEE, // Money.of(getTransactionCurrencyCode(), Math.round(forexFees * exchangeRate.doubleValue())), // Money.of(getSecurityCurrencyCode(), forexFees), // exchangeRate)); if (forexTaxes != 0) transaction.addUnit(new Transaction.Unit(Transaction.Unit.Type.TAX, // Money.of(getTransactionCurrencyCode(), Math.round(forexTaxes * exchangeRate.doubleValue())), // Money.of(getSecurityCurrencyCode(), forexTaxes), // exchangeRate)); transaction.addUnit(new Transaction.Unit(Transaction.Unit.Type.GROSS_VALUE, // Money.of(getTransactionCurrencyCode(), convertedGrossValue), // Money.of(getSecurityCurrencyCode(), grossValue), // exchangeRate)); } } @Override public IStatus getCalculationStatus() { return calculationStatus; } /** * Check whether calculation works out. The separate validation is needed * because the model does prevent negative values in methods * {@link #calcGrossValue(long, long, long)} and * {@link #calcTotal(long, long, long)}. Due to the limited precision of the * quote (2 digits currently) and the exchange rate (4 digits), the gross * value and converted gross value are checked against a range. */ private IStatus calculateStatus() { // check whether gross value is in range long lower = Math.round(shares * quote.add(BigDecimal.valueOf(-0.01)).doubleValue() * Values.Amount.factor() / Values.Share.divider()); long upper = Math.round(shares * quote.add(BigDecimal.valueOf(0.01)).doubleValue() * Values.Amount.factor() / Values.Share.divider()); if (grossValue < lower || grossValue > upper) return ValidationStatus.error(Messages.MsgIncorrectSubTotal); // check whether converted gross value is in range upper = Math.round(grossValue * exchangeRate.add(BigDecimal.valueOf(0.0001)).doubleValue()); lower = Math.round(grossValue * exchangeRate.add(BigDecimal.valueOf(-0.0001)).doubleValue()); if (convertedGrossValue < lower || convertedGrossValue > upper) return ValidationStatus.error(Messages.MsgIncorrectConvertedSubTotal); // check total long t = calculateTotal(); if (t != total) return ValidationStatus.error(MessageFormat.format(Messages.MsgIncorrectTotal, Values.Amount.format(t))); if (total == 0L) return ValidationStatus.error(MessageFormat.format(Messages.MsgDialogInputRequired, Messages.ColumnTotal)); return ValidationStatus.ok(); } public Portfolio getPortfolio() { return portfolio; } public void setPortfolio(Portfolio portfolio) { firePropertyChange(Properties.portfolio.name(), this.portfolio, this.portfolio = portfolio); if (security != null) { updateSharesAndQuote(); updateExchangeRate(); } } public Security getSecurity() { return security; } public void setSecurity(Security security) { String oldCurrencyCode = getSecurityCurrencyCode(); String oldExchangeRateCurrencies = getExchangeRateCurrencies(); String oldInverseExchangeRateCurrencies = getInverseExchangeRateCurrencies(); firePropertyChange(Properties.security.name(), this.security, this.security = security); firePropertyChange(Properties.securityCurrencyCode.name(), oldCurrencyCode, getSecurityCurrencyCode()); firePropertyChange(Properties.exchangeRateCurrencies.name(), oldExchangeRateCurrencies, getExchangeRateCurrencies()); firePropertyChange(Properties.inverseExchangeRateCurrencies.name(), oldInverseExchangeRateCurrencies, getInverseExchangeRateCurrencies()); updateSharesAndQuote(); updateExchangeRate(); } protected void updateSharesAndQuote() { if (type == PortfolioTransaction.Type.SELL || type == PortfolioTransaction.Type.DELIVERY_OUTBOUND) { boolean hasPosition = false; if (portfolio != null) { // since the security position has always the currency of the // investment vehicle, actually no conversion is needed. Hence // we can use an arbitrary converter. CurrencyConverter converter = new CurrencyConverterImpl(getExchangeRateProviderFactory(), CurrencyUnit.EUR); PortfolioSnapshot snapshot = PortfolioSnapshot.create(portfolio, converter, date); SecurityPosition position = snapshot.getPositionsBySecurity().get(security); if (position != null) { setShares(position.getShares()); setTotal(position.calculateValue().getAmount()); hasPosition = true; } } if (!hasPosition) { setShares(0); setQuote(new BigDecimal(security.getSecurityPrice(date).getValue() / Values.Quote.divider())); } } else { setQuote(new BigDecimal(security.getSecurityPrice(date).getValue() / Values.Quote.divider())); } } protected void updateExchangeRate() { if (getTransactionCurrencyCode().equals(getSecurityCurrencyCode())) { setExchangeRate(BigDecimal.ONE); } else if (!getTransactionCurrencyCode().isEmpty() && !getSecurityCurrencyCode().isEmpty()) { ExchangeRateTimeSeries series = getExchangeRateProviderFactory() // .getTimeSeries(getSecurityCurrencyCode(), getTransactionCurrencyCode()); if (series != null) setExchangeRate(series.lookupRate(date).orElse(new ExchangeRate(date, BigDecimal.ONE)).getValue()); else setExchangeRate(BigDecimal.ONE); } } public LocalDate getDate() { return date; } public void setDate(LocalDate date) { firePropertyChange(Properties.date.name(), this.date, this.date = date); updateExchangeRate(); } public long getShares() { return shares; } public void setShares(long shares) { firePropertyChange(Properties.shares.name(), this.shares, this.shares = shares); if (quote.doubleValue() != 0) { triggerGrossValue( Math.round(shares * quote.doubleValue() * Values.Amount.factor() / Values.Share.divider())); } else if (grossValue != 0 && shares != 0) { setQuote(new BigDecimal(grossValue * Values.Share.factor() / (shares * Values.Amount.divider()))); } firePropertyChange(Properties.calculationStatus.name(), this.calculationStatus, this.calculationStatus = calculateStatus()); } public BigDecimal getQuote() { return quote; } public void setQuote(BigDecimal quote) { firePropertyChange(Properties.quote.name(), this.quote, this.quote = quote); triggerGrossValue(Math.round(shares * quote.doubleValue() * Values.Amount.factor() / Values.Share.divider())); firePropertyChange(Properties.calculationStatus.name(), this.calculationStatus, this.calculationStatus = calculateStatus()); } public long getGrossValue() { return grossValue; } public void setGrossValue(long grossValue) { triggerGrossValue(grossValue); if (shares != 0) { BigDecimal newQuote = new BigDecimal( grossValue * Values.Share.factor() / (shares * Values.Amount.divider())); firePropertyChange(Properties.quote.name(), this.quote, this.quote = newQuote); } firePropertyChange(Properties.calculationStatus.name(), this.calculationStatus, this.calculationStatus = calculateStatus()); } public void triggerGrossValue(long grossValue) { firePropertyChange(Properties.grossValue.name(), this.grossValue, this.grossValue = grossValue); triggerConvertedGrossValue(Math.round(exchangeRate.doubleValue() * grossValue)); } public BigDecimal getExchangeRate() { return exchangeRate; } public void setExchangeRate(BigDecimal exchangeRate) { BigDecimal newRate = exchangeRate == null ? BigDecimal.ZERO : exchangeRate; BigDecimal oldInverseRate = getInverseExchangeRate(); firePropertyChange(Properties.exchangeRate.name(), this.exchangeRate, this.exchangeRate = newRate); firePropertyChange(Properties.inverseExchangeRate.name(), oldInverseRate, getInverseExchangeRate()); triggerConvertedGrossValue(Math.round(newRate.doubleValue() * grossValue)); firePropertyChange(Properties.calculationStatus.name(), this.calculationStatus, this.calculationStatus = calculateStatus()); } public BigDecimal getInverseExchangeRate() { return BigDecimal.ONE.divide(exchangeRate, 10, BigDecimal.ROUND_HALF_DOWN); } public void setInverseExchangeRate(BigDecimal rate) { setExchangeRate(BigDecimal.ONE.divide(rate, 10, BigDecimal.ROUND_HALF_DOWN)); } public long getConvertedGrossValue() { return convertedGrossValue; } public void setConvertedGrossValue(long convertedGrossValue) { triggerConvertedGrossValue(convertedGrossValue); if (grossValue != 0) { BigDecimal newExchangeRate = BigDecimal.valueOf(convertedGrossValue).divide(BigDecimal.valueOf(grossValue), 10, RoundingMode.HALF_UP); BigDecimal oldInverseRate = getInverseExchangeRate(); firePropertyChange(Properties.exchangeRate.name(), this.exchangeRate, this.exchangeRate = newExchangeRate); firePropertyChange(Properties.inverseExchangeRate.name(), oldInverseRate, getInverseExchangeRate()); triggerTotal(calculateTotal()); // forex fees and taxes might change } firePropertyChange(Properties.calculationStatus.name(), this.calculationStatus, this.calculationStatus = calculateStatus()); } public void triggerConvertedGrossValue(long convertedGrossValue) { firePropertyChange(Properties.convertedGrossValue.name(), this.convertedGrossValue, this.convertedGrossValue = convertedGrossValue); triggerTotal(calculateTotal()); } public long getFees() { return fees; } public void setFees(long fees) { firePropertyChange(Properties.fees.name(), this.fees, this.fees = fees); triggerTotal(calculateTotal()); firePropertyChange(Properties.calculationStatus.name(), this.calculationStatus, this.calculationStatus = calculateStatus()); } public long getForexFees() { return forexFees; } public void setForexFees(long forexFees) { firePropertyChange(Properties.forexFees.name(), this.forexFees, this.forexFees = forexFees); triggerTotal(calculateTotal()); firePropertyChange(Properties.calculationStatus.name(), this.calculationStatus, this.calculationStatus = calculateStatus()); } public long getTaxes() { return taxes; } public void setTaxes(long taxes) { firePropertyChange(Properties.taxes.name(), this.taxes, this.taxes = taxes); triggerTotal(calculateTotal()); firePropertyChange(Properties.calculationStatus.name(), this.calculationStatus, this.calculationStatus = calculateStatus()); } public long getForexTaxes() { return forexTaxes; } public void setForexTaxes(long forexTaxes) { firePropertyChange(Properties.forexTaxes.name(), this.forexTaxes, this.forexTaxes = forexTaxes); triggerTotal(calculateTotal()); firePropertyChange(Properties.calculationStatus.name(), this.calculationStatus, this.calculationStatus = calculateStatus()); } public long getTotal() { return total; } public void setTotal(long total) { triggerTotal(total); firePropertyChange(Properties.convertedGrossValue.name(), this.convertedGrossValue, this.convertedGrossValue = calculateConvertedGrossValue()); firePropertyChange(Properties.grossValue.name(), this.grossValue, this.grossValue = Math.round(convertedGrossValue / exchangeRate.doubleValue())); if (shares != 0) firePropertyChange(Properties.quote.name(), this.quote, this.quote = new BigDecimal( grossValue * Values.Share.factor() / (shares * Values.Amount.divider()))); firePropertyChange(Properties.calculationStatus.name(), this.calculationStatus, this.calculationStatus = calculateStatus()); } public void triggerTotal(long total) { firePropertyChange(Properties.total.name(), this.total, this.total = total); } public String getNote() { return note; } public void setNote(String note) { firePropertyChange(Properties.note.name(), this.note, this.note = note); } public String getSecurityCurrencyCode() { return security != null ? security.getCurrencyCode() : ""; //$NON-NLS-1$ } public abstract String getTransactionCurrencyCode(); /** * Returns exchange rate label in direct (price) notation. */ public String getExchangeRateCurrencies() { return String.format("%s/%s", getSecurityCurrencyCode(), getTransactionCurrencyCode()); //$NON-NLS-1$ } /** * Returns exchange rate label in indirect (quantity) notation. */ public String getInverseExchangeRateCurrencies() { return String.format("%s/%s", getTransactionCurrencyCode(), getSecurityCurrencyCode()); //$NON-NLS-1$ } public PortfolioTransaction.Type getType() { return type; } protected long calculateConvertedGrossValue() { long feesAndTaxes = fees + taxes + Math.round(exchangeRate.doubleValue() * (forexFees + forexTaxes)); switch (type) { case BUY: case DELIVERY_INBOUND: return Math.max(0, total - feesAndTaxes); case SELL: case DELIVERY_OUTBOUND: return total + feesAndTaxes; default: throw new UnsupportedOperationException(); } } private long calculateTotal() { long feesAndTaxes = fees + taxes + Math.round(exchangeRate.doubleValue() * (forexFees + forexTaxes)); switch (type) { case BUY: case DELIVERY_INBOUND: return convertedGrossValue + feesAndTaxes; case SELL: case DELIVERY_OUTBOUND: return Math.max(0, convertedGrossValue - feesAndTaxes); default: throw new UnsupportedOperationException(); } } }