From ef79fcbf59918f67a9b20650fd8fbfdebf5db91e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Santos=20=28UMa=29?= Date: Sat, 4 Jul 2026 17:01:30 +0100 Subject: [PATCH] Add standalone IRR chart view to Performance Extracts the IRR calculation from the generic configurable chart into its own dedicated and clean view in the Performance sidebar. Handles maximum and minimum bounds and preserves horizontal quartile markers for IRR independently. --- .../portfolio/ui/editor/Navigation.java | 2 + .../ui/util/chart/TimelineChart.java | 43 +++- .../portfolio/ui/views/IRRChartView.java | 209 ++++++++++++++++++ .../PerformanceChartSeriesBuilder.java | 3 + 4 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/IRRChartView.java diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/editor/Navigation.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/editor/Navigation.java index 467f09a833..9fa4ce2958 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/editor/Navigation.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/editor/Navigation.java @@ -45,6 +45,7 @@ import name.abuchen.portfolio.ui.views.BrowserTestView; import name.abuchen.portfolio.ui.views.GroupedAccountsListView; import name.abuchen.portfolio.ui.views.InvestmentPlanListView; +import name.abuchen.portfolio.ui.views.IRRChartView; import name.abuchen.portfolio.ui.views.PerformanceChartView; import name.abuchen.portfolio.ui.views.PerformanceView; import name.abuchen.portfolio.ui.views.PortfolioListView; @@ -499,6 +500,7 @@ private void createPerformanceSection() performance.add(new Item(Messages.ClientEditorPerformanceCalculation, PerformanceView.class)); performance.add(new Item(Messages.ClientEditorLabelChart, PerformanceChartView.class, true)); + performance.add(new Item("IRR", IRRChartView.class, true)); performance.add(new Item(Messages.ClientEditorLabelReturnsVolatility, ReturnsVolatilityChartView.class, true)); performance.add(new Item(Messages.LabelSecurities, SecuritiesPerformanceView.class)); performance.add(new Item(Messages.LabelPayments, PaymentsView.class, true)); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/chart/TimelineChart.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/chart/TimelineChart.java index 7cc746c056..6c888da942 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/chart/TimelineChart.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/util/chart/TimelineChart.java @@ -55,6 +55,20 @@ public long getEpochDay() } } + private static class HorizontalMarkerLine + { + private Color color; + private String label; + private double value; + + private HorizontalMarkerLine(Color color, String label, double value) + { + this.color = color; + this.label = label; + this.value = value; + } + } + private static class NonTradingDayMarker { private LocalDate date; @@ -73,6 +87,7 @@ public long getEpochDay() } private List markerLines = new ArrayList<>(); + private List horizMarkerLines = new ArrayList<>(); private List nonTradingDayMarkers = new ArrayList<>(); private Map addedAxis = new HashMap<>(); @@ -152,6 +167,16 @@ public void clearMarkerLines() this.markerLines.clear(); } + public void addHorizontalMarkerLine(double value, Color color, String label) + { + this.horizMarkerLines.add(new HorizontalMarkerLine(color, label, value)); + } + + public void clearHorizontalMarkerLines() + { + this.horizMarkerLines.clear(); + } + public void addNonTradingDayMarker(LocalDate date, Color color) { this.nonTradingDayMarkers.add(new NonTradingDayMarker(date, color)); @@ -251,7 +276,7 @@ private void paintTimeGrid(PaintEvent e) private void paintMarkerLines(PaintEvent e) // NOSONAR { - if (markerLines.isEmpty()) + if (markerLines.isEmpty() && horizMarkerLines.isEmpty()) return; IAxis xAxis = getAxisSet().getXAxis(0); @@ -286,6 +311,22 @@ private void paintMarkerLines(PaintEvent e) // NOSONAR e.gc.drawLine(x - 5, y, x + 5, y); } } + + for (HorizontalMarkerLine marker : horizMarkerLines) + { + int y = yAxis.getPixelCoordinate(marker.value); + + e.gc.setLineStyle(SWT.LINE_DASHDOT); + e.gc.setForeground(marker.color); + e.gc.setLineWidth(1); + e.gc.drawLine(0, y, e.width, y); + + if (marker.label != null) + { + Point textExtent = e.gc.textExtent(marker.label); + e.gc.drawText(marker.label, 10, y - textExtent.y - 2, true); + } + } } private void paintNonTradingDayMarker(PaintEvent eventNonTradingDay) // NOSONAR diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/IRRChartView.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/IRRChartView.java new file mode 100644 index 0000000000..17c9feaa2f --- /dev/null +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/IRRChartView.java @@ -0,0 +1,209 @@ +package name.abuchen.portfolio.ui.views; + +import java.io.File; +import java.io.IOException; +import java.text.DecimalFormat; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import jakarta.annotation.PostConstruct; + +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.IMenuListener; +import org.eclipse.jface.action.IMenuManager; +import org.eclipse.jface.action.Separator; +import org.eclipse.jface.action.ToolBarManager; +import org.eclipse.jface.layout.GridDataFactory; +import org.eclipse.jface.layout.GridLayoutFactory; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Shell; + +import name.abuchen.portfolio.ui.Images; +import name.abuchen.portfolio.ui.Messages; +import name.abuchen.portfolio.ui.util.AbstractCSVExporter; +import name.abuchen.portfolio.ui.util.Colors; +import name.abuchen.portfolio.ui.util.DropDown; +import name.abuchen.portfolio.ui.util.chart.TimelineChart; +import name.abuchen.portfolio.ui.views.AbstractHistoricView; +import name.abuchen.portfolio.ui.util.chart.TimelineChartCSVExporter; +import name.abuchen.portfolio.ui.util.format.AxisTickPercentNumberFormat; +import name.abuchen.portfolio.snapshot.IRRSeries; +import name.abuchen.portfolio.snapshot.PerformanceIndex; +import name.abuchen.portfolio.money.CurrencyConverter; +import name.abuchen.portfolio.money.CurrencyConverterImpl; +import name.abuchen.portfolio.money.ExchangeRateProviderFactory; +import name.abuchen.portfolio.util.ColorConversion; + +public class IRRChartView extends AbstractHistoricView +{ + private TimelineChart chart; + + @Override + protected String getDefaultTitle() + { + return "IRR"; + } + + @Override + protected Composite createBody(Composite parent) + { + Composite composite = new Composite(parent, SWT.NONE); + composite.setBackground(Colors.theme().defaultBackground()); + + chart = new TimelineChart(composite); + chart.getTitle().setText("IRR"); + chart.getTitle().setVisible(false); + chart.getAxisSet().getYAxis(0).getTick().setFormat(new AxisTickPercentNumberFormat("0.#%")); //$NON-NLS-1$ + chart.getToolTip().setDefaultValueFormat(new DecimalFormat("0.##%")); + chart.getToolTip().reverseLabels(true); + + GridLayoutFactory.fillDefaults().numColumns(1).margins(0, 0).spacing(0, 0).applyTo(composite); + GridDataFactory.fillDefaults().grab(true, true).applyTo(chart); + + updateChart(); + return composite; + } + + @Override + protected void addButtons(ToolBarManager toolBar) + { + super.addButtons(toolBar); + toolBar.add(new ExportDropDown()); + } + + @Override + public void setFocus() + { + chart.adjustRange(); + chart.setFocus(); + } + + @Override + public void reportingPeriodUpdated() + { + notifyModelUpdated(); + } + + @Override + public void notifyModelUpdated() + { + updateChart(); + } + + private void updateChart() + { + chart.suspendUpdate(true); + try + { + chart.getTitle().setText("IRR"); + for (var s : chart.getSeriesSet().getSeries()) + chart.getSeriesSet().deleteSeries(s.getId()); + + chart.clearHorizontalMarkerLines(); + + ExchangeRateProviderFactory factory = make(ExchangeRateProviderFactory.class); + CurrencyConverter converter = new CurrencyConverterImpl(factory, getClient().getBaseCurrency()); + java.util.List warnings = new java.util.ArrayList<>(); + PerformanceIndex clientIndex = PerformanceIndex.forClient(getClient(), converter, getReportingPeriod().toInterval(LocalDate.now()), warnings); + + var irrValues = IRRSeries.calculate(clientIndex); + var allDates = clientIndex.getDates(); + + double maxTotal = 0.1; + double minTotal = -0.1; + double[] totals = clientIndex.getAccumulatedPercentage(); + if (totals != null) { + for (double v : totals) { + if (Double.isFinite(v)) { + if (v > maxTotal) maxTotal = v; + if (v < minTotal) minTotal = v; + } + } + } + + double capMax = Math.max(0.2, maxTotal * 1.5); + double capMin = Math.min(-0.2, minTotal * 1.5); + + int validCount = 0; + for (double v : irrValues) { + if (Double.isFinite(v)) validCount++; + } + + LocalDate[] validDates = new LocalDate[validCount]; + double[] validValues = new double[validCount]; + int idx = 0; + for (int i = 0; i < irrValues.length; i++) { + if (Double.isFinite(irrValues[i])) { + validDates[idx] = allDates[i]; + validValues[idx] = Math.max(capMin, Math.min(capMax, irrValues[i])); + idx++; + } + } + + if (validCount > 0) { + var irrLineSeries = chart.addDateSeries("irr-main-series", + validDates, validValues, Messages.LabelIRR); + irrLineSeries.setLineColor(Colors.getColor(ColorConversion.hex2RGB("#034EFF"))); + irrLineSeries.setLineWidth(2); + + double[] sortedValues = Arrays.copyOf(validValues, validCount); + Arrays.sort(sortedValues); + + double minV = sortedValues[0]; + double p5 = sortedValues[Math.max(0, (int)(validCount * 0.05))]; + double median = sortedValues[Math.max(0, (int)(validCount * 0.50))]; + double p95 = sortedValues[Math.min(validCount - 1, (int)(validCount * 0.95))]; + double maxV = sortedValues[validCount - 1]; + + var colorMin = Colors.getColor(ColorConversion.hex2RGB("#D90429")); + var colorP5 = Colors.getColor(ColorConversion.hex2RGB("#F27438")); + var colorMed = Colors.getColor(ColorConversion.hex2RGB("#E6AF2E")); + var colorP95 = Colors.getColor(ColorConversion.hex2RGB("#8CD373")); + var colorMax = Colors.getColor(ColorConversion.hex2RGB("#2B9348")); + + chart.addHorizontalMarkerLine(minV, colorMin, String.format("Mínimo (%.1f%%)", minV * 100)); + chart.addHorizontalMarkerLine(p5, colorP5, String.format("Percentil 5%% (%.1f%%)", p5 * 100)); + chart.addHorizontalMarkerLine(median, colorMed, String.format("Mediana (%.1f%%)", median * 100)); + chart.addHorizontalMarkerLine(p95, colorP95, String.format("Percentil 95%% (%.1f%%)", p95 * 100)); + chart.addHorizontalMarkerLine(maxV, colorMax, String.format("Máximo (%.1f%%)", maxV * 100)); + } + + chart.adjustRange(); + } + finally + { + chart.suspendUpdate(false); + } + chart.redraw(); + } + + private final class ExportDropDown extends DropDown implements IMenuListener + { + private ExportDropDown() + { + super(Messages.MenuExportData, Images.EXPORT, SWT.NONE); + setMenuListener(this); + } + + @Override + public void menuAboutToShow(IMenuManager manager) + { + manager.add(new Action(Messages.MenuExportChartData) + { + @Override + public void run() + { + TimelineChartCSVExporter exporter = new TimelineChartCSVExporter(chart); + exporter.setValueFormat(new DecimalFormat("0.##########")); //$NON-NLS-1$ + exporter.export("IRR.csv"); //$NON-NLS-1$ + } + }); + + manager.add(new Separator()); + chart.exportMenuAboutToShow(manager, "IRR"); + } + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/dataseries/PerformanceChartSeriesBuilder.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/dataseries/PerformanceChartSeriesBuilder.java index d9055b44b8..975f65b19e 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/dataseries/PerformanceChartSeriesBuilder.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/dataseries/PerformanceChartSeriesBuilder.java @@ -1,10 +1,13 @@ package name.abuchen.portfolio.ui.views.dataseries; import java.time.LocalDate; +import java.util.Arrays; import org.eclipse.swt.graphics.Color; +import org.eclipse.swtchart.LineStyle; import name.abuchen.portfolio.snapshot.Aggregation; +import name.abuchen.portfolio.snapshot.IRRSeries; import name.abuchen.portfolio.snapshot.PerformanceIndex; import name.abuchen.portfolio.ui.Messages; import name.abuchen.portfolio.ui.util.Colors;