Skip to content
85 changes: 85 additions & 0 deletions IRRCalculationAfterTax.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package name.abuchen.portfolio.snapshot.security;

import name.abuchen.portfolio.model.AccountTransaction;
import name.abuchen.portfolio.model.PortfolioTransaction;
import name.abuchen.portfolio.money.CurrencyConverter;
import name.abuchen.portfolio.money.Values;

/**
* Calculates the internal rate of return (IRR) of a security based on the
* actual, after-tax cash flows, i.e. unlike {@link IRRCalculation} taxes are
* NOT added back to reconstruct a value before taxes:
* <ul>
* <li>dividends are used at their net (post-withholding) amount</li>
* <li>buys are used at the full amount actually paid (including taxes)</li>
* <li>sells are used at the full amount actually received (after
* taxes)</li>
* <li>standalone tax transactions and tax refunds linked to the security
* (i.e. not already part of a buy, sell or dividend) are included as real
* cash flows instead of being ignored</li>
* </ul>
* Fees continue to be treated exactly as in {@link IRRCalculation}, i.e.
* always as real cash flows, because they are never added back in the gross
* calculation either.
*/
/* package */ class IRRCalculationAfterTax extends IRRCalculation
{
@Override
public void visit(CurrencyConverter converter, CalculationLineItem.DividendPayment t)
{
dates.add(t.getDateTime().toLocalDate());

long amount = t.getValue().with(converter.at(t.getDateTime())).getAmount();

values.add(amount / Values.Amount.divider());
}

@Override
public void visit(CurrencyConverter converter, CalculationLineItem.TransactionItem item, AccountTransaction t)
{
switch (t.getType())
{
case TAXES:
// unlike the gross IRR, a standalone tax charge on the
// security is a real cash outflow and must be counted
dates.add(t.getDateTime().toLocalDate());
values.add(-converter.convert(t.getDateTime(), t.getMonetaryAmount()).getAmount()
/ Values.Amount.divider());
break;
case TAX_REFUND:
dates.add(t.getDateTime().toLocalDate());
values.add(converter.convert(t.getDateTime(), t.getMonetaryAmount()).getAmount()
/ Values.Amount.divider());
break;
default:
// FEES / FEES_REFUND are handled identically to the gross
// calculation; all other types are ignored, same as in the
// parent class
super.visit(converter, item, t);
}
}

@Override
public void visit(CurrencyConverter converter, CalculationLineItem.TransactionItem item, PortfolioTransaction t)
{
dates.add(t.getDateTime().toLocalDate());

long amount = t.getMonetaryAmount(converter).getAmount();

switch (t.getType())
{
case BUY:
case DELIVERY_INBOUND:
case TRANSFER_IN:
values.add(-amount / Values.Amount.divider());
break;
case SELL:
case DELIVERY_OUTBOUND:
case TRANSFER_OUT:
values.add(amount / Values.Amount.divider());
break;
default:
throw new UnsupportedOperationException();
}
}
}
114 changes: 114 additions & 0 deletions IRRCalculationAfterTaxTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package name.abuchen.portfolio.snapshot.security;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertTrue;

import java.time.LocalDateTime;
import java.time.Month;
import java.util.ArrayList;
import java.util.List;

import org.hamcrest.number.IsCloseTo;
import org.junit.Test;

import name.abuchen.portfolio.junit.TestCurrencyConverter;
import name.abuchen.portfolio.model.Account;
import name.abuchen.portfolio.model.AccountTransaction;
import name.abuchen.portfolio.model.Portfolio;
import name.abuchen.portfolio.model.PortfolioTransaction;
import name.abuchen.portfolio.model.Security;
import name.abuchen.portfolio.model.Transaction.Unit;
import name.abuchen.portfolio.money.CurrencyUnit;
import name.abuchen.portfolio.money.Money;
import name.abuchen.portfolio.money.Values;

@SuppressWarnings("nls")
public class IRRCalculationAfterTaxTest
{
@Test
public void testDividendPaymentsWithTaxes()
{
// same scenario as IRRCalculationTest#testDividendPaymentsWithTaxes,
// but this time the tax withheld on the dividend and the tax paid on
// the sale must NOT be added back

List<CalculationLineItem> tx = new ArrayList<>();

Portfolio portfolio = new Portfolio();
Security security = new Security();

tx.add(CalculationLineItem.of(portfolio,
new PortfolioTransaction(LocalDateTime.of(2015, Month.DECEMBER, 31, 0, 0), //
CurrencyUnit.EUR, Values.Amount.factorize(1000), //
security, Values.Share.factorize(10), PortfolioTransaction.Type.BUY, //
Values.Amount.factorize(10), 0)));

AccountTransaction t = new AccountTransaction();
t.setType(AccountTransaction.Type.DIVIDENDS);
t.setDateTime(LocalDateTime.parse("2016-06-01T00:00"));
t.setSecurity(security);
t.setMonetaryAmount(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(100)));
t.setShares(Values.Share.factorize(10));
t.addUnit(new Unit(Unit.Type.TAX, Money.of(CurrencyUnit.EUR, Values.Amount.factorize(50))));
tx.add(CalculationLineItem.of(new Account(), t));

tx.add(CalculationLineItem.of(portfolio,
new PortfolioTransaction(LocalDateTime.of(2016, Month.DECEMBER, 31, 0, 0), //
CurrencyUnit.EUR, Values.Amount.factorize(1200), //
security, Values.Share.factorize(10), PortfolioTransaction.Type.SELL, //
Values.Amount.factorize(10), Values.Amount.factorize(30))));

IRRCalculationAfterTax calculation = Calculation.perform(IRRCalculationAfterTax.class,
new TestCurrencyConverter(), security, tx);

// Excel verification
// 31.12.15 -1000 (unchanged: this buy has no taxes)
// 01.06.16 100 (net dividend, tax NOT added back)
// 31.12.16 1200 (net sale proceeds, tax NOT added back)
// =XINTZINSFUSS(B1:B3;A1:A3) = 0,316409186

assertThat(calculation.getIRR(), IsCloseTo.closeTo(0.316409186d, 0.000001d));
}

@Test
public void testStandaloneTaxTransactionIsIncludedAsCashFlow()
{
// a standalone TAXES transaction linked to the security (e.g. a tax
// adjustment not tied to a specific buy/sell/dividend) must be
// counted as a real cash outflow in the after-tax IRR, whereas the
// gross IRRCalculation always ignores it

List<CalculationLineItem> tx = new ArrayList<>();

Portfolio portfolio = new Portfolio();
Security security = new Security();

tx.add(CalculationLineItem.of(portfolio,
new PortfolioTransaction(LocalDateTime.of(2020, Month.JANUARY, 1, 0, 0), //
CurrencyUnit.EUR, Values.Amount.factorize(1000), //
security, Values.Share.factorize(10), PortfolioTransaction.Type.BUY, 0, 0)));

AccountTransaction standaloneTax = new AccountTransaction();
standaloneTax.setType(AccountTransaction.Type.TAXES);
standaloneTax.setDateTime(LocalDateTime.of(2020, Month.JULY, 1, 0, 0));
standaloneTax.setSecurity(security);
standaloneTax.setMonetaryAmount(Money.of(CurrencyUnit.EUR, Values.Amount.factorize(20)));
tx.add(CalculationLineItem.of(new Account(), standaloneTax));

tx.add(CalculationLineItem.of(portfolio,
new PortfolioTransaction(LocalDateTime.of(2021, Month.JANUARY, 1, 0, 0), //
CurrencyUnit.EUR, Values.Amount.factorize(1100), //
security, Values.Share.factorize(10), PortfolioTransaction.Type.SELL, 0, 0)));

IRRCalculationAfterTax afterTax = Calculation.perform(IRRCalculationAfterTax.class,
new TestCurrencyConverter(), security, tx);
IRRCalculation gross = Calculation.perform(IRRCalculation.class, new TestCurrencyConverter(), security, tx);

// the gross IRR ignores the standalone tax charge entirely
assertThat(gross.getIRR(), IsCloseTo.closeTo(0.1d, 0.001d));

// the after-tax IRR must be lower because it counts the 20 EUR tax
// charge as a real cash outflow
assertTrue(afterTax.getIRR() < gross.getIRR());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ public class Messages extends NLS
public static String ColumnInterval;
public static String ColumnIRR;
public static String ColumnIRR_MenuLabel;
public static String ColumnIRRAfterTax;
public static String ColumnIRRAfterTax_Description;
public static String ColumnIRRAfterTax_MenuLabel;
public static String ColumnIRRPerformance;
public static String ColumnIRRPerformanceOption;
public static String ColumnISIN;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,12 @@ ColumnHoldingPeriod = Holding period (days)

ColumnIRR = IRR

ColumnIRRAfterTax = IRR (after tax)

ColumnIRRAfterTax_Description = Internal rate of return based on the actual after-tax cash flows, i.e. taxes are not added back to reconstruct a gross value.

ColumnIRRAfterTax_MenuLabel = Internal rate of return after taxes

ColumnIRRPerformance = IRR Performance

ColumnIRRPerformanceOption = IRR {0}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,12 @@ ColumnHoldingPeriod = Periodo detenzione (gg)

ColumnIRR = IRR

ColumnIRRAfterTax = IRR (al netto delle imposte)

ColumnIRRAfterTax_Description = Tasso di rendimento interno calcolato sui flussi di cassa realmente al netto delle imposte, senza ricostruire il valore lordo.

ColumnIRRAfterTax_MenuLabel = Tasso di rendimento interno al netto delle imposte

ColumnIRRPerformance = Performance IRR

ColumnIRRPerformanceOption = IRR {0}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,17 @@ private void addPerformanceColumns()
column.setSorter(ColumnViewerSorter.create(e -> ((LazySecurityPerformanceRecord) e).getIrr()));
recordColumns.addColumn(column);

// internal rate of return after taxes
column = new Column("izf_after_tax", Messages.ColumnIRRAfterTax, SWT.RIGHT, 80); //$NON-NLS-1$
column.setGroupLabel(Messages.GroupLabelPerformance);
column.setMenuLabel(Messages.ColumnIRRAfterTax_MenuLabel);
column.setDescription(Messages.ColumnIRRAfterTax_Description);
column.setLabelProvider(new RowElementLabelProvider(new NumberColorLabelProvider<>(Values.Percent2,
r -> ((LazySecurityPerformanceRecord) r).getIrrAfterTax())));
column.setSorter(ColumnViewerSorter.create(e -> ((LazySecurityPerformanceRecord) e).getIrrAfterTax()));
column.setVisible(false);
recordColumns.addColumn(column);

column = new Column("capitalgains", Messages.ColumnCapitalGains, SWT.RIGHT, 80); //$NON-NLS-1$
column.setGroupLabel(Messages.GroupLabelPerformance);
column.setDescription(Messages.ColumnCapitalGains_Description);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,21 @@
import name.abuchen.portfolio.money.CurrencyConverter;
import name.abuchen.portfolio.money.Values;

/**
* Calculates the internal rate of return (IRR) of a security based on the
* gross cash flows, i.e. taxes withheld on dividends and paid on sales are
* added back to reconstruct the value before taxes. Fees, on the other
* hand, are always treated as real cash flows.
*
* @see IRRCalculationAfterTax for the after-tax variant of this calculation
*/
/* package */class IRRCalculation extends Calculation
{
private List<LocalDate> dates = new ArrayList<>();
private List<Double> values = new ArrayList<>();
// package-visible (not private) so that IRRCalculationAfterTax can reuse
// the ValuationAtStart/ValuationAtEnd handling and append its own,
// after-tax cash flows for dividends, buys/sells and taxes.
protected List<LocalDate> dates = new ArrayList<>();
protected List<Double> values = new ArrayList<>();

@Override
public void visit(CurrencyConverter converter, CalculationLineItem.ValuationAtStart t)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ public V get()
private final LazyValue<Double> irr = new LazyValue<>(
() -> Calculation.perform(IRRCalculation.class, converter, security, lineItems).getIRR());

/**
* internal rate of return of security after taxes {@link #calculateIRR()}
*/
private final LazyValue<Double> irrAfterTax = new LazyValue<>(() -> Calculation
.perform(IRRCalculationAfterTax.class, converter, security, lineItems).getIRR());

/**
* weak reference to the performance index, because it can consume a lot of
* memory in particular for large intervals
Expand Down Expand Up @@ -183,11 +189,16 @@ private Money getCostMoney(CostMethod costMethod, TaxesAndFees taxesAndFees)
super(client, security, converter, interval);
}

public Double getIrr()
public Double getIrr()
{
return irr.get();
}

public Double getIrrAfterTax()
{
return irrAfterTax.get();
}

public Double getTrueTimeWeightedRateOfReturn()
{
return twror.get();
Expand Down