package name.abuchen.portfolio.snapshot;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import name.abuchen.portfolio.Messages;
import name.abuchen.portfolio.model.Account;
import name.abuchen.portfolio.model.AccountTransaction;
import name.abuchen.portfolio.model.Client;
import name.abuchen.portfolio.model.Portfolio;
import name.abuchen.portfolio.model.PortfolioTransaction;
import name.abuchen.portfolio.model.Security;
import name.abuchen.portfolio.model.Transaction;
import name.abuchen.portfolio.model.Transaction.Unit;
import name.abuchen.portfolio.model.TransactionPair;
import name.abuchen.portfolio.money.CurrencyConverter;
import name.abuchen.portfolio.money.Money;
import name.abuchen.portfolio.money.MoneyCollectors;
import name.abuchen.portfolio.money.MutableMoney;
public class ClientPerformanceSnapshot
{
public static class Position
{
private Money valuation;
private String label;
private Security security;
public Position(Security security, Money valuation)
{
this.label = security.getName();
this.valuation = valuation;
this.security = security;
}
public Position(String label, Money valuation)
{
this.label = label;
this.valuation = valuation;
}
public Money getValuation()
{
return valuation;
}
public String getLabel()
{
return label;
}
public Security getSecurity()
{
return security;
}
}
public static class Category
{
private List<Position> positions = new ArrayList<>();
private String label;
private String sign;
private Money valuation;
public Category(String label, String sign, Money valuation)
{
this.label = label;
this.sign = sign;
this.valuation = valuation;
}
public Money getValuation()
{
return valuation;
}
public String getLabel()
{
return label;
}
public String getSign()
{
return sign;
}
public List<Position> getPositions()
{
return positions;
}
}
public enum CategoryType
{
INITIAL_VALUE, CAPITAL_GAINS, EARNINGS, FEES, TAXES, CURRENCY_GAINS, TRANSFERS, FINAL_VALUE
}
private final Client client;
private final CurrencyConverter converter;
private final ReportingPeriod period;
private ClientSnapshot snapshotStart;
private ClientSnapshot snapshotEnd;
private final EnumMap<CategoryType, Category> categories = new EnumMap<>(CategoryType.class);
private final List<TransactionPair<?>> earnings = new ArrayList<>();
private final List<TransactionPair<?>> fees = new ArrayList<>();
private final List<TransactionPair<?>> taxes = new ArrayList<>();
private double irr;
public ClientPerformanceSnapshot(Client client, CurrencyConverter converter, LocalDate startDate, LocalDate endDate)
{
this(client, converter, new ReportingPeriod.FromXtoY(startDate, endDate));
}
public ClientPerformanceSnapshot(Client client, CurrencyConverter converter, ReportingPeriod period)
{
this.client = client;
this.converter = converter;
this.period = period;
this.snapshotStart = ClientSnapshot.create(client, converter, period.getStartDate());
this.snapshotEnd = ClientSnapshot.create(client, converter, period.getEndDate());
calculate();
}
public Client getClient()
{
return client;
}
public ClientSnapshot getStartClientSnapshot()
{
return snapshotStart;
}
public ClientSnapshot getEndClientSnapshot()
{
return snapshotEnd;
}
public List<Category> getCategories()
{
return new ArrayList<>(categories.values());
}
public Category getCategoryByType(CategoryType type)
{
return categories.get(type);
}
public Money getValue(CategoryType categoryType)
{
return categories.get(categoryType).getValuation();
}
public List<TransactionPair<?>> getEarnings()
{
return earnings;
}
public List<TransactionPair<?>> getFees()
{
return fees;
}
public List<TransactionPair<?>> getTaxes()
{
return taxes;
}
public double getPerformanceIRR()
{
return irr;
}
public Money getAbsoluteDelta()
{
MutableMoney delta = MutableMoney.of(converter.getTermCurrency());
for (Map.Entry<CategoryType, Category> entry : categories.entrySet())
{
switch (entry.getKey())
{
case CAPITAL_GAINS:
case EARNINGS:
case CURRENCY_GAINS:
delta.add(entry.getValue().getValuation());
break;
case FEES:
case TAXES:
delta.subtract(entry.getValue().getValuation());
break;
default:
break;
}
}
return delta.toMoney();
}
private void calculate()
{
categories.put(CategoryType.INITIAL_VALUE,
new Category(String.format(Messages.ColumnInitialValue, snapshotStart.getTime()), "", //$NON-NLS-1$
snapshotStart.getMonetaryAssets()));
Money zero = Money.of(converter.getTermCurrency(), 0);
categories.put(CategoryType.CAPITAL_GAINS, new Category(Messages.ColumnCapitalGains, "+", zero)); //$NON-NLS-1$
categories.put(CategoryType.EARNINGS, new Category(Messages.ColumnEarnings, "+", zero)); //$NON-NLS-1$
categories.put(CategoryType.FEES, new Category(Messages.ColumnPaidFees, "-", zero)); //$NON-NLS-1$
categories.put(CategoryType.TAXES, new Category(Messages.ColumnPaidTaxes, "-", zero)); //$NON-NLS-1$
categories.put(CategoryType.CURRENCY_GAINS, new Category(Messages.ColumnCurrencyGains, "+", zero)); //$NON-NLS-1$
categories.put(CategoryType.TRANSFERS, new Category(Messages.ColumnTransfers, "+", zero)); //$NON-NLS-1$
categories.put(CategoryType.FINAL_VALUE,
new Category(String.format(Messages.ColumnFinalValue, snapshotEnd.getTime()), "=", //$NON-NLS-1$
snapshotEnd.getMonetaryAssets()));
irr = ClientIRRYield.create(client, snapshotStart, snapshotEnd).getIrr();
addCapitalGains();
addEarnings();
addCurrencyGains();
}
private void addCapitalGains()
{
Map<Security, MutableMoney> valuation = new HashMap<>();
for (Security s : client.getSecurities())
valuation.put(s, MutableMoney.of(converter.getTermCurrency()));
snapshotStart.getJointPortfolio().getPositions().stream().forEach(p -> valuation.get(p.getInvestmentVehicle())
.subtract(p.calculateValue().with(converter.at(snapshotStart.getTime()))));
for (PortfolioTransaction t : snapshotStart.getJointPortfolio().getSource().getTransactions())
{
if (!period.containsTransaction().test(t))
continue;
switch (t.getType())
{
case BUY:
case DELIVERY_INBOUND:
case TRANSFER_IN:
valuation.get(t.getSecurity()).subtract(t.getGrossValue().with(converter.at(t.getDate())));
break;
case SELL:
case DELIVERY_OUTBOUND:
case TRANSFER_OUT:
valuation.get(t.getSecurity()).add(t.getGrossValue().with(converter.at(t.getDate())));
break;
default:
throw new UnsupportedOperationException();
}
}
snapshotEnd.getJointPortfolio().getPositions().stream().forEach(p -> valuation.get(p.getInvestmentVehicle())
.add(p.calculateValue().with(converter.at(snapshotEnd.getTime()))));
Category capitalGains = categories.get(CategoryType.CAPITAL_GAINS);
// add securities w/ capital gains to the positions
capitalGains.positions = valuation.entrySet().stream() //
.filter(entry -> !entry.getValue().isZero())
.map(entry -> new Position(entry.getKey(), entry.getValue().toMoney()))
.sorted((p1, p2) -> p1.getLabel().compareToIgnoreCase(p2.getLabel())) //
.collect(Collectors.toList());
// total capital gains -> sum it up
capitalGains.valuation = capitalGains.positions.stream() //
.map(p -> p.getValuation()) //
.collect(MoneyCollectors.sum(converter.getTermCurrency()));
}
private void addEarnings()
{
MutableMoney mEarnings = MutableMoney.of(converter.getTermCurrency());
MutableMoney mOtherEarnings = MutableMoney.of(converter.getTermCurrency());
MutableMoney mFees = MutableMoney.of(converter.getTermCurrency());
MutableMoney mTaxes = MutableMoney.of(converter.getTermCurrency());
MutableMoney mDeposits = MutableMoney.of(converter.getTermCurrency());
MutableMoney mRemovals = MutableMoney.of(converter.getTermCurrency());
Map<Security, MutableMoney> earningsBySecurity = new HashMap<>();
for (Account account : client.getAccounts())
{
for (AccountTransaction t : account.getTransactions())
{
if (!period.containsTransaction().test(t))
continue;
switch (t.getType())
{
case DIVIDENDS:
case INTEREST:
addEarningTransaction(account, t, mEarnings, mOtherEarnings, mTaxes, earningsBySecurity);
break;
case INTEREST_CHARGE:
Money charged = t.getMonetaryAmount().with(converter.at(t.getDate()));
mEarnings.subtract(t.getMonetaryAmount().with(converter.at(t.getDate())));
earnings.add(new TransactionPair<AccountTransaction>(account, t));
mOtherEarnings.subtract(charged);
break;
case DEPOSIT:
mDeposits.add(t.getMonetaryAmount().with(converter.at(t.getDate())));
break;
case REMOVAL:
mRemovals.add(t.getMonetaryAmount().with(converter.at(t.getDate())));
break;
case FEES:
mFees.add(t.getMonetaryAmount().with(converter.at(t.getDate())));
fees.add(new TransactionPair<AccountTransaction>(account, t));
break;
case FEES_REFUND:
mFees.subtract(t.getMonetaryAmount().with(converter.at(t.getDate())));
fees.add(new TransactionPair<AccountTransaction>(account, t));
break;
case TAXES:
mTaxes.add(t.getMonetaryAmount().with(converter.at(t.getDate())));
taxes.add(new TransactionPair<AccountTransaction>(account, t));
break;
case TAX_REFUND:
mTaxes.subtract(t.getMonetaryAmount().with(converter.at(t.getDate())));
taxes.add(new TransactionPair<AccountTransaction>(account, t));
break;
case BUY:
case SELL:
case TRANSFER_IN:
case TRANSFER_OUT:
// no operation
break;
default:
throw new UnsupportedOperationException();
}
}
}
for (Portfolio portfolio : client.getPortfolios())
{
for (PortfolioTransaction t : portfolio.getTransactions())
{
if (!period.containsTransaction().test(t))
continue;
Money unit = t.getUnitSum(Unit.Type.FEE, converter);
if (!unit.isZero())
{
mFees.add(unit);
fees.add(new TransactionPair<PortfolioTransaction>(portfolio, t));
}
unit = t.getUnitSum(Unit.Type.TAX, converter);
if (!unit.isZero())
{
mTaxes.add(unit);
taxes.add(new TransactionPair<PortfolioTransaction>(portfolio, t));
}
switch (t.getType())
{
case DELIVERY_INBOUND:
mDeposits.add(t.getMonetaryAmount().with(converter.at(t.getDate())));
break;
case DELIVERY_OUTBOUND:
mRemovals.add(t.getMonetaryAmount().with(converter.at(t.getDate())));
break;
case BUY:
case SELL:
case TRANSFER_IN:
case TRANSFER_OUT:
break;
default:
throw new UnsupportedOperationException();
}
}
}
Category earningsCategory = categories.get(CategoryType.EARNINGS);
earningsCategory.valuation = mEarnings.toMoney();
earningsCategory.positions = earningsBySecurity.entrySet().stream()
//
.filter(entry -> !entry.getValue().isZero())
.map(entry -> new Position(entry.getKey(), entry.getValue().toMoney()))
.sorted((p1, p2) -> p1.getLabel().compareToIgnoreCase(p2.getLabel())) //
.collect(Collectors.toList());
if (!mOtherEarnings.isZero())
earningsCategory.positions.add(new Position(Messages.LabelInterest, mOtherEarnings.toMoney()));
categories.get(CategoryType.FEES).valuation = mFees.toMoney();
categories.get(CategoryType.TAXES).valuation = mTaxes.toMoney();
categories.get(CategoryType.TRANSFERS).valuation = mDeposits.toMoney().subtract(mRemovals.toMoney());
categories.get(CategoryType.TRANSFERS).positions.add(new Position(Messages.LabelDeposits, mDeposits.toMoney()));
categories.get(CategoryType.TRANSFERS).positions.add(new Position(Messages.LabelRemovals, mRemovals.toMoney()));
}
private void addEarningTransaction(Account account, AccountTransaction transaction, MutableMoney mEarnings,
MutableMoney mOtherEarnings, MutableMoney mTaxes, Map<Security, MutableMoney> earningsBySecurity)
{
this.earnings.add(new TransactionPair<AccountTransaction>(account, transaction));
Money tax = transaction.getUnitSum(Unit.Type.TAX, converter).with(converter.at(transaction.getDate()));
Money earned = transaction.getGrossValue().with(converter.at(transaction.getDate()));
mEarnings.add(earned);
if (!tax.isZero())
{
mTaxes.add(tax);
taxes.add(new TransactionPair<AccountTransaction>(account, transaction));
}
if (transaction.getSecurity() != null)
earningsBySecurity.computeIfAbsent(transaction.getSecurity(),
k -> MutableMoney.of(converter.getTermCurrency())).add(earned);
else
mOtherEarnings.add(earned);
}
private void addCurrencyGains()
{
Map<String, MutableMoney> currency2money = new HashMap<>();
for (AccountSnapshot snapshot : snapshotStart.getAccounts())
{
if (converter.getTermCurrency().equals(snapshot.getAccount().getCurrencyCode()))
continue;
MutableMoney value = currency2money.computeIfAbsent(snapshot.getAccount().getCurrencyCode(),
c -> MutableMoney.of(converter.getTermCurrency()));
// subtract initial values
value.subtract(snapshot.getFunds());
// add and subtract transactions
for (AccountTransaction t : snapshot.getAccount().getTransactions())
{
if (!period.containsTransaction().test(t))
continue;
switch (t.getType())
{
case DIVIDENDS:
case INTEREST:
case DEPOSIT:
case TAX_REFUND:
case SELL:
case FEES_REFUND:
value.subtract(t.getMonetaryAmount().with(converter.at(t.getDate())));
break;
case REMOVAL:
case FEES:
case INTEREST_CHARGE:
case TAXES:
case BUY:
value.add(t.getMonetaryAmount().with(converter.at(t.getDate())));
break;
case TRANSFER_IN:
value.subtract(determineTransferAmount(t));
break;
case TRANSFER_OUT:
value.add(determineTransferAmount(t));
break;
default:
throw new UnsupportedOperationException();
}
}
}
// add final values (if in foreign currency)
for (AccountSnapshot snapshot : snapshotEnd.getAccounts())
{
if (converter.getTermCurrency().equals(snapshot.getAccount().getCurrencyCode()))
continue;
currency2money.computeIfAbsent(snapshot.getAccount().getCurrencyCode(),
c -> MutableMoney.of(converter.getTermCurrency())) //
.add(snapshot.getFunds());
}
Category currencyGains = categories.get(CategoryType.CURRENCY_GAINS);
currency2money.forEach((currency, money) -> {
currencyGains.valuation = currencyGains.valuation.add(money.toMoney());
currencyGains.positions.add(new Position(currency, money.toMoney()));
});
Collections.sort(currencyGains.positions, (p1, p2) -> p1.getLabel().compareTo(p2.getLabel()));
}
/**
* Determine the monetary amount when transferring cash between accounts.
* Because the actual exchange rate of the transferal might differ from the
* historical rate given by the exchange rate provider (e.g. ECB), we would
* get rounding differences if we do not take the original amount. If the
* transferal does not involve the term currency at all, we calculate the
* average value out of both converted amounts.
*/
private Money determineTransferAmount(AccountTransaction t)
{
if (converter.getTermCurrency().equals(t.getCurrencyCode()))
return t.getMonetaryAmount();
Transaction other = t.getCrossEntry().getCrossTransaction(t);
if (converter.getTermCurrency().equals(other.getCurrencyCode()))
return other.getMonetaryAmount();
MutableMoney m = MutableMoney.of(converter.getTermCurrency());
m.add(t.getMonetaryAmount().with(converter.at(t.getDate())));
m.add(other.getMonetaryAmount().with(converter.at(t.getDate())));
return m.divide(2).toMoney();
}
}