diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/InvestmentPlanTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/InvestmentPlanTest.java index f12b34334f..1b9e609bdc 100644 --- a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/InvestmentPlanTest.java +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/model/InvestmentPlanTest.java @@ -290,4 +290,78 @@ public void testErrorMessageWhenNoQuotesExist() throws IOException investmentPlan.generateTransactions(new TestCurrencyConverter()); } + @Test + public void testGenerationOfWeeklyBuyTransaction() throws IOException + { + investmentPlan.setType(InvestmentPlan.Type.PURCHASE_OR_DELIVERY); + + investmentPlan.setAccount(account); // set both account and portfolio + investmentPlan.setPortfolio(portfolio); // causes securities to be + // bought + investmentPlan.setSecurity(security); + investmentPlan.setStart(LocalDateTime.parse("2016-01-01T00:00:00")); + + investmentPlan.setInterval(100); // 100 is weekly, 200 is every two + // weeks + + investmentPlan.generateTransactions(new TestCurrencyConverter()); + + List tx = investmentPlan.getTransactions().stream() + .filter(t -> t.getDateTime().isBefore(LocalDateTime.parse("2016-06-01T00:00"))) + .collect(Collectors.toList()); + + assertThat(tx.size(), is(22)); + + // Friday 1st January 2016 is holiday. The real first transaction is + // Monday 4 January 2016 + tx = investmentPlan.getTransactions().stream().collect(Collectors.toList()); + assertThat(tx.get(0), instanceOf(PortfolioTransaction.class)); + assertThat(tx.get(0).getDateTime(), is(LocalDateTime.parse("2016-01-04T00:00"))); + assertThat(((PortfolioTransaction) tx.get(0)).getType(), is(PortfolioTransaction.Type.BUY)); + + tx = investmentPlan.getTransactions().stream() + .filter(t -> t.getDateTime().getYear() == 2016 && t.getDateTime().getMonth() == Month.MARCH) + .collect(Collectors.toList()); + + assertThat(investmentPlan.getPlanType(), is(InvestmentPlan.Type.PURCHASE_OR_DELIVERY)); + + // March 2016 has Friday 25 as holiday and Monday 28 too : the 25th + // March transaction should be offset to 29th March + + assertThat(tx.size(), is(4)); + assertThat(tx.get(0), instanceOf(PortfolioTransaction.class)); + assertThat(tx.get(1), instanceOf(PortfolioTransaction.class)); + + assertThat(tx.get(0).getDateTime(), is(LocalDateTime.parse("2016-03-04T00:00"))); + assertThat(((PortfolioTransaction) tx.get(0)).getType(), is(PortfolioTransaction.Type.BUY)); + + assertThat(tx.get(1).getDateTime(), is(LocalDateTime.parse("2016-03-11T00:00"))); + assertThat(((PortfolioTransaction) tx.get(1)).getType(), is(PortfolioTransaction.Type.BUY)); + + assertThat(tx.get(3).getDateTime(), is(LocalDateTime.parse("2016-03-29T00:00"))); + assertThat(((PortfolioTransaction) tx.get(1)).getType(), is(PortfolioTransaction.Type.BUY)); + + // there are 5 Fridays in April 2016. Check that the weekly plan is back + // on Friday after the Tuesday 29 March 2016 transaction. + List txApril = investmentPlan.getTransactions().stream() + .filter(t -> t.getDateTime().getYear() == 2016 && t.getDateTime().getMonth() == Month.APRIL) + .collect(Collectors.toList()); + assertThat(txApril.size(), is(5)); + assertThat(txApril.get(0), instanceOf(PortfolioTransaction.class)); + assertThat(txApril.get(0).getDateTime(), is(LocalDateTime.parse("2016-04-01T00:00"))); + assertThat(((PortfolioTransaction) txApril.get(0)).getType(), is(PortfolioTransaction.Type.BUY)); + + // check that delta generation of transactions also takes into account + // the Calendar + investmentPlan.getTransactions().stream() + .filter(t -> t.getDateTime().isAfter(LocalDateTime.parse("2016-03-20T00:00"))) + .collect(Collectors.toList()) + .forEach(t -> investmentPlan.removeTransaction((PortfolioTransaction) t)); + + List> newlyGenerated = investmentPlan.generateTransactions(new TestCurrencyConverter()); + assertThat(newlyGenerated.isEmpty(), is(false)); + assertThat(newlyGenerated.get(0).getTransaction(), instanceOf(PortfolioTransaction.class)); + assertThat(newlyGenerated.get(0).getTransaction().getDateTime(), is(LocalDateTime.parse("2016-03-29T00:00"))); + } + } 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 23daf0ef8b..1d65548915 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 @@ -482,6 +482,7 @@ public class Messages extends NLS public static String InvestmentPlanAutoCreationJob; public static String InvestmentPlanInfoNoTransactionsGenerated; public static String InvestmentPlanIntervalLabel; + public static String InvestmentPlanIntervalWeeklyLabel; public static String InvestmentPlanMenuCreate; public static String InvestmentPlanMenuDelete; public static String InvestmentPlanMenuGenerateTransactions; diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/InvestmentPlanDialog.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/InvestmentPlanDialog.java index bebc3707cc..a3aa04ae82 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/InvestmentPlanDialog.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/InvestmentPlanDialog.java @@ -139,8 +139,8 @@ protected void createFormElements(Composite editArea) // interval List available = new ArrayList<>(); - for (int ii = 1; ii <= 12; ii++) - available.add(ii); + for (var entry : InvestmentPlanModel.Intervals.values()) + available.add(entry.getInterval()); ComboInput interval = new ComboInput(editArea, Messages.ColumnInterval); interval.value.setInput(available); @@ -150,7 +150,7 @@ protected void createFormElements(Composite editArea) public String getText(Object element) { int interval = (Integer) element; - return MessageFormat.format(Messages.InvestmentPlanIntervalLabel, interval); + return InvestmentPlanModel.Intervals.get(interval).toString(); } }); interval.bindValue(Properties.interval.name(), diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/InvestmentPlanModel.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/InvestmentPlanModel.java index 3079301240..20e455b4f2 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/InvestmentPlanModel.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/dialogs/transactions/InvestmentPlanModel.java @@ -45,6 +45,55 @@ public enum Properties private IStatus calculationStatus = ValidationStatus.ok(); + public enum Intervals + { + WEEKLY(100), // + BIWEEKLY(200), // + MONTHLY(1), // + MONTHLY2(2), // + MONTHLY3(3), // + MONTHLY4(4), // + MONTHLY5(5), // + MONTHLY6(6), // + MONTHLY7(7), // + MONTHLY8(8), // + MONTHLY9(9), // + MONTHLY10(10), // + MONTHLY11(11), // + MONTHLY12(12); // + + private final Integer interval; + + private Intervals(Integer interval) + { + this.interval = interval; + } + + public Integer getInterval() + { + return interval; + } + + public static Intervals get(Integer interval) + { + for (Intervals e : Intervals.values()) + { + if (e.interval.equals(interval)) + return e; + } + throw new IllegalArgumentException("unknown interval"); //$NON-NLS-1$ + } + + @Override + public String toString() + { + if (interval <= 12) // monthly + return MessageFormat.format(Messages.InvestmentPlanIntervalLabel, interval); + else // weekly or biweekly + return MessageFormat.format(Messages.InvestmentPlanIntervalWeeklyLabel, interval / 100); + } + } + public InvestmentPlanModel(Client client, InvestmentPlan.Type planType) { this.client = client; 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 1dbab48036..7914c36a9f 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 @@ -956,6 +956,8 @@ InvestmentPlanInfoNoTransactionsGenerated = No transactions generated.\nThe next InvestmentPlanIntervalLabel = {0,choice,1#monthly|1 comboBoxItems; + private List comboItemsNames; - public ListEditingSupport(Class subjectType, String attributeName, List options) + public ListEditingSupport(Class subjectType, String attributeName, List options, List comboItemsNames) { super(subjectType, attributeName); @@ -27,6 +28,12 @@ public ListEditingSupport(Class subjectType, String attributeName, List op throw new IllegalArgumentException("option must not be null"); //$NON-NLS-1$ this.comboBoxItems = new ArrayList<>(options); + this.comboItemsNames = comboItemsNames; + } + + public ListEditingSupport(Class subjectType, String attributeName, List options) + { + this(subjectType, attributeName, options, null); } public boolean canBeNull(Object element) // NOSONAR @@ -60,8 +67,21 @@ public final void prepareEditor(Object element) String[] names = new String[comboBoxItems.size()]; int index = 0; - for (Object item : comboBoxItems) - names[index++] = item == null ? "" : item.toString(); //$NON-NLS-1$ + if (comboItemsNames == null) + { + for (Object item : comboBoxItems) + names[index++] = item == null ? "" : item.toString(); //$NON-NLS-1$ + } + else if (comboItemsNames.size() == comboBoxItems.size()) + { + for (Object item : comboBoxItems) + { + names[index] = item == null ? "" : comboItemsNames.get(index); //$NON-NLS-1$ + index++; + } + } + else + throw new IllegalArgumentException("arrays size do not match"); //$NON-NLS-1$ editor.setItems(names); } diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/InvestmentPlanListView.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/InvestmentPlanListView.java index 0271e5f1e4..d448f3f31d 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/InvestmentPlanListView.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/InvestmentPlanListView.java @@ -37,6 +37,7 @@ import name.abuchen.portfolio.ui.Messages; import name.abuchen.portfolio.ui.PortfolioPlugin; import name.abuchen.portfolio.ui.dialogs.transactions.InvestmentPlanDialog; +import name.abuchen.portfolio.ui.dialogs.transactions.InvestmentPlanModel; import name.abuchen.portfolio.ui.dialogs.transactions.OpenDialogAction; import name.abuchen.portfolio.ui.editor.AbstractFinanceView; import name.abuchen.portfolio.ui.editor.PortfolioPart; @@ -271,14 +272,20 @@ public Image getImage(Object e) @Override public String getText(Object e) { - return MessageFormat.format(Messages.InvestmentPlanIntervalLabel, ((InvestmentPlan) e).getInterval()); + int interval = ((InvestmentPlan) e).getInterval(); + return InvestmentPlanModel.Intervals.get(interval).toString(); } }); ColumnViewerSorter.create(InvestmentPlan.class, "interval").attachTo(column); //$NON-NLS-1$ List available = new ArrayList<>(); - for (int ii = 1; ii <= 12; ii++) - available.add(ii); - new ListEditingSupport(InvestmentPlan.class, "interval", available).addListener(this).attachTo(column); //$NON-NLS-1$ + List theIntervalNames = new ArrayList<>(); + for (var entry : InvestmentPlanModel.Intervals.values()) + { + available.add(entry.getInterval()); + theIntervalNames.add(entry.toString()); + } + new ListEditingSupport(InvestmentPlan.class, "interval", available, theIntervalNames).addListener(this) //$NON-NLS-1$ + .attachTo(column); support.addColumn(column); column = new Column(Messages.ColumnAmount, SWT.RIGHT, 80); diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java index 91ecfb28b6..37316163f0 100644 --- a/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/model/InvestmentPlan.java @@ -49,6 +49,8 @@ public enum Type private List transactions = new ArrayList<>(); + private LocalDate weeklyNominalDate; + public InvestmentPlan() { // needed for xstream de-serialization @@ -307,37 +309,46 @@ public Optional getLastDate() private LocalDate next(LocalDate transactionDate) { LocalDate previousDate = transactionDate; - - // the transaction date might be edited (or moved to the next months b/c - // of public holidays) -> determine the "normalized" date by comparing - // the three months around the current transactionDate - - if (transactionDate.getDayOfMonth() != start.getDayOfMonth()) + LocalDate next; + if (interval <= 12) // monthly invervals { - int daysBetween = Integer.MAX_VALUE; + // the transaction date might be edited (or moved to the next months b/c + // of public holidays) -> determine the "normalized" date by comparing + // the three months around the current transactionDate - LocalDate testDate = transactionDate.minusMonths(1); - testDate = testDate.withDayOfMonth(Math.min(testDate.lengthOfMonth(), start.getDayOfMonth())); - - for (int ii = 0; ii < 3; ii++) + if (transactionDate.getDayOfMonth() != start.getDayOfMonth()) { - int d = Dates.daysBetween(transactionDate, testDate); - if (d < daysBetween) - { - daysBetween = d; - previousDate = testDate; - } + int daysBetween = Integer.MAX_VALUE; - testDate = testDate.plusMonths(1); + LocalDate testDate = transactionDate.minusMonths(1); testDate = testDate.withDayOfMonth(Math.min(testDate.lengthOfMonth(), start.getDayOfMonth())); + + for (int ii = 0; ii < 3; ii++) + { + int d = Dates.daysBetween(transactionDate, testDate); + if (d < daysBetween) + { + daysBetween = d; + previousDate = testDate; + } + + testDate = testDate.plusMonths(1); + testDate = testDate.withDayOfMonth(Math.min(testDate.lengthOfMonth(), start.getDayOfMonth())); + } } - } - LocalDate next = previousDate.plusMonths(interval); + next = previousDate.plusMonths(interval); + // correct day of month (say the transactions are to be generated on the + // 31st, but the month has only 30 days) + next = next.withDayOfMonth(Math.min(next.lengthOfMonth(), start.getDayOfMonth())); + } + else // weekly or bi weekly intervals + { + if (weeklyNominalDate != null) + previousDate = weeklyNominalDate; - // correct day of month (say the transactions are to be generated on the - // 31st, but the month has only 30 days) - next = next.withDayOfMonth(Math.min(next.lengthOfMonth(), start.getDayOfMonth())); + next = previousDate.plusWeeks(interval / 100); + } if (next.isBefore(start.toLocalDate())) { @@ -345,6 +356,9 @@ private LocalDate next(LocalDate transactionDate) next = start.toLocalDate(); } + if (interval > 12) // weekly intervals are 100 or 200 + weeklyNominalDate = next; + // do not generate a investment plan transaction on a public holiday TradeCalendar tradeCalendar = security != null ? TradeCalendarManager.getInstance(security) : TradeCalendarManager.getDefaultInstance(); @@ -356,15 +370,19 @@ private LocalDate next(LocalDate transactionDate) public LocalDate getDateOfNextTransactionToBeGenerated() { + weeklyNominalDate = null; Optional lastDate = getLastDate(); if (lastDate.isPresent()) { - return next(lastDate.get()); + LocalDate nextDate = next(lastDate.get()); + weeklyNominalDate = nextDate; + return nextDate; } else { LocalDate startDate = start.toLocalDate(); + weeklyNominalDate = startDate; // do not generate a investment plan transaction on a public holiday TradeCalendar tradeCalendar = security != null ? TradeCalendarManager.getInstance(security) @@ -393,6 +411,8 @@ public List> generateTransactions(CurrencyConverter converter transactionDate = next(transactionDate); } + weeklyNominalDate = null; + return newlyCreated; }