diff --git a/IRRCalculationAfterTax.java b/IRRCalculationAfterTax.java new file mode 100644 index 0000000000..4b78c8aeda --- /dev/null +++ b/IRRCalculationAfterTax.java @@ -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: + * + * 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(); + } + } +} diff --git a/IRRCalculationAfterTaxTest.java b/IRRCalculationAfterTaxTest.java new file mode 100644 index 0000000000..38e6adad83 --- /dev/null +++ b/IRRCalculationAfterTaxTest.java @@ -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 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 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()); + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/Messages.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/Messages.java index 41da24899c..1bf5db1859 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/Messages.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/Messages.java @@ -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; diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties index e2f6cacefc..2444d64f12 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages.properties @@ -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} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties index 912600c3ef..f7f437fdbe 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/messages_it.properties @@ -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} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/SecuritiesPerformanceView.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/SecuritiesPerformanceView.java index 6ef5ce4801..a41fa69261 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/SecuritiesPerformanceView.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/SecuritiesPerformanceView.java @@ -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); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/snapshot/security/IRRCalculation.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/snapshot/security/IRRCalculation.java index f6e7452d61..109a0a0b5f 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/snapshot/security/IRRCalculation.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/snapshot/security/IRRCalculation.java @@ -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 dates = new ArrayList<>(); - private List 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 dates = new ArrayList<>(); + protected List values = new ArrayList<>(); @Override public void visit(CurrencyConverter converter, CalculationLineItem.ValuationAtStart t) diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/snapshot/security/LazySecurityPerformanceRecord.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/snapshot/security/LazySecurityPerformanceRecord.java index a97b865a60..42b3398cba 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/snapshot/security/LazySecurityPerformanceRecord.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/snapshot/security/LazySecurityPerformanceRecord.java @@ -68,6 +68,12 @@ public V get() private final LazyValue irr = new LazyValue<>( () -> Calculation.perform(IRRCalculation.class, converter, security, lineItems).getIRR()); + /** + * internal rate of return of security after taxes {@link #calculateIRR()} + */ + private final LazyValue 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 @@ -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();