From ea119c009f2f35d7ab93b1e06d757db9f50ab427 Mon Sep 17 00:00:00 2001 From: Christoph Massmann Date: Sun, 9 Oct 2022 16:36:27 +0200 Subject: [PATCH] moved security timeliness calculations into separate model and added unit tests, added new configuration tab for quotes and moved existing config setting to update quotes on startup there, included list of stale securities into tooltip --- .../util/SecurityTimelinessTest.java | 120 ++++++++++++++++++ .../abuchen/portfolio/ui/UIConstants.java | 2 + .../handlers/OpenPreferenceDialogHandler.java | 3 + .../ui/preferences/GeneralPreferencePage.java | 3 - .../preferences/PreferencesInitializer.java | 1 + .../ui/preferences/QuotesPreferencePage.java | 32 +++++ .../SecurityPriceTimelinessWidget.java | 61 ++++++--- .../portfolio/util/SecurityTimeliness.java | 48 +++++++ 8 files changed, 246 insertions(+), 24 deletions(-) create mode 100644 name.abuchen.portfolio.tests/src/name/abuchen/portfolio/util/SecurityTimelinessTest.java create mode 100644 name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/QuotesPreferencePage.java create mode 100644 name.abuchen.portfolio/src/name/abuchen/portfolio/util/SecurityTimeliness.java diff --git a/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/util/SecurityTimelinessTest.java b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/util/SecurityTimelinessTest.java new file mode 100644 index 0000000000..80d785af93 --- /dev/null +++ b/name.abuchen.portfolio.tests/src/name/abuchen/portfolio/util/SecurityTimelinessTest.java @@ -0,0 +1,120 @@ +package name.abuchen.portfolio.util; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; + +import org.junit.Test; + +import name.abuchen.portfolio.model.LatestSecurityPrice; +import name.abuchen.portfolio.model.Security; + +@SuppressWarnings("nls") +public class SecurityTimelinessTest +{ + private static final LocalDate LOCAL_DATE = LocalDate.of(2020, 5, 6); + private Clock clock; + + public SecurityTimelinessTest() + { + this.clock = Clock.fixed(LOCAL_DATE.atStartOfDay(ZoneId.systemDefault()).toInstant(), ZoneId.systemDefault()); + } + + @Test + public void testStaleIfNoLatestFeed() + { + Security security = new Security(); + security.setRetired(false); + + SecurityTimeliness st = new SecurityTimeliness(security, 7, this.clock); + + assertTrue(st.isStale()); + } + + @Test + public void testNotStaleIfRetired() + { + Security security = new Security(); + security.setRetired(true); + + SecurityTimeliness st = new SecurityTimeliness(security, 7, this.clock); + + assertFalse(st.isStale()); + } + + @Test + public void testStaleIfNotUpdatedWithin8DaysWith1HolidayAndWeekend() + { + Security security = new Security(); + security.setRetired(false); + security.setLatest(new LatestSecurityPrice(LocalDate.of(2020, 4, 23), 10)); + + SecurityTimeliness st = new SecurityTimeliness(security, 7, this.clock); + + assertTrue(st.isStale()); + } + + @Test + public void testNotStaleIfNotUpdatedWithin7DaysWith1HolidayAndWeekend() + { + Security security = new Security(); + security.setRetired(false); + security.setLatest(new LatestSecurityPrice(LocalDate.of(2020, 4, 24), 10)); + + SecurityTimeliness st = new SecurityTimeliness(security, 7, this.clock); + + assertFalse(st.isStale()); + } + + @Test + public void testNotStaleIfUpdatedToday() + { + Security security = new Security(); + security.setRetired(false); + security.setLatest(new LatestSecurityPrice(LocalDate.of(2020, 5, 6), 10)); + + SecurityTimeliness st = new SecurityTimeliness(security, 7, this.clock); + + assertFalse(st.isStale()); + } + + @Test + public void testNotStaleIfUpdatedYesterday() + { + Security security = new Security(); + security.setRetired(false); + security.setLatest(new LatestSecurityPrice(LocalDate.of(2020, 5, 5), 10)); + + SecurityTimeliness st = new SecurityTimeliness(security, 7, this.clock); + + assertFalse(st.isStale()); + } + + @Test + public void testStaleIfUpdatedYesterdayAndInterval0() + { + Security security = new Security(); + security.setRetired(false); + security.setLatest(new LatestSecurityPrice(LocalDate.of(2020, 5, 5), 10)); + + SecurityTimeliness st = new SecurityTimeliness(security, 0, this.clock); + + assertTrue(st.isStale()); + } + + @Test + public void testNoHolidaysOrWeekendsIfSecurityHasNoCalendar() + { + Security security = new Security(); + security.setRetired(false); + security.setLatest(new LatestSecurityPrice(LocalDate.of(2020, 4, 28), 10)); + security.setCalendar("empty"); + + SecurityTimeliness st = new SecurityTimeliness(security, 7, this.clock); + + assertTrue(st.isStale()); + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/UIConstants.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/UIConstants.java index 0d483029e4..bf65d9dd3a 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/UIConstants.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/UIConstants.java @@ -204,6 +204,8 @@ interface Preferences // NOSONAR * Preference for directory from which to import CSV files */ String CSV_IMPORT_PATH = "CSV_IMPORT_PATH"; //$NON-NLS-1$ + + String QUOTES_STALE_AFTER_DAYS_PATH = "QUOTES_STALE_AFTER_DAYS_PATH"; //$NON-NLS-1$ } interface CSS // NOSONAR diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/handlers/OpenPreferenceDialogHandler.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/handlers/OpenPreferenceDialogHandler.java index f465e03d6d..2dad70ca26 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/handlers/OpenPreferenceDialogHandler.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/handlers/OpenPreferenceDialogHandler.java @@ -32,6 +32,7 @@ import name.abuchen.portfolio.ui.preferences.PresetsPreferencePage; import name.abuchen.portfolio.ui.preferences.ProxyPreferencePage; import name.abuchen.portfolio.ui.preferences.QuandlPreferencePage; +import name.abuchen.portfolio.ui.preferences.QuotesPreferencePage; import name.abuchen.portfolio.ui.preferences.ThemePreferencePage; import name.abuchen.portfolio.ui.preferences.UpdatePreferencePage; @@ -69,6 +70,8 @@ public void execute(@Named(IServiceConstants.ACTIVE_SHELL) Shell shell, pm.addToRoot(new PreferenceNode("calendar", new CalendarPreferencePage())); //$NON-NLS-1$ + pm.addToRoot(new PreferenceNode("quotes", new QuotesPreferencePage())); + pm.addToRoot(new PreferenceNode("api", new APIKeyPreferencePage())); //$NON-NLS-1$ pm.addTo("api", new PreferenceNode("alphavantage", new AlphaVantagePreferencePage())); //$NON-NLS-1$ //$NON-NLS-2$ pm.addTo("api", new PreferenceNode("divvydiary", new DivvyDiaryPreferencePage())); //$NON-NLS-1$ //$NON-NLS-2$ diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/GeneralPreferencePage.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/GeneralPreferencePage.java index 0819f23109..2db77801d1 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/GeneralPreferencePage.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/GeneralPreferencePage.java @@ -18,9 +18,6 @@ public GeneralPreferencePage() @Override public void createFieldEditors() { - addField(new BooleanFieldEditor(UIConstants.Preferences.UPDATE_QUOTES_AFTER_FILE_OPEN, // - Messages.PrefUpdateQuotesAfterFileOpen, getFieldEditorParent())); - addField(new BooleanFieldEditor(UIConstants.Preferences.STORE_SETTINGS_NEXT_TO_FILE, // Messages.PrefStoreSettingsNextToFile, getFieldEditorParent())); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/PreferencesInitializer.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/PreferencesInitializer.java index 82ec6d73db..9d788c36a7 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/PreferencesInitializer.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/PreferencesInitializer.java @@ -33,6 +33,7 @@ public void initializeDefaultPreferences() store.setDefault(UIConstants.Preferences.CALENDAR, "default"); //$NON-NLS-1$ store.setDefault(UIConstants.Preferences.PORTFOLIO_REPORT_API_URL, "https://api.portfolio-report.net"); //$NON-NLS-1$ store.setDefault(UIConstants.Preferences.PRESET_VALUE_TIME, PresetValues.TimePreset.MIDNIGHT.name()); + store.setDefault(UIConstants.Preferences.QUOTES_STALE_AFTER_DAYS_PATH, 7); // Backup store.setDefault(UIConstants.Preferences.BACKUP_MODE, BackupMode.getDefault().name()); diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/QuotesPreferencePage.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/QuotesPreferencePage.java new file mode 100644 index 0000000000..45faf0ab12 --- /dev/null +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/preferences/QuotesPreferencePage.java @@ -0,0 +1,32 @@ +package name.abuchen.portfolio.ui.preferences; + +import org.eclipse.jface.preference.BooleanFieldEditor; +import org.eclipse.jface.preference.FieldEditorPreferencePage; +import org.eclipse.jface.preference.IntegerFieldEditor; + +import name.abuchen.portfolio.ui.Messages; +import name.abuchen.portfolio.ui.UIConstants; + +public class QuotesPreferencePage extends FieldEditorPreferencePage +{ + public QuotesPreferencePage() + { + super(GRID); + setTitle("Quotes"); + } + + @Override + public void createFieldEditors() + { + addField(new BooleanFieldEditor(UIConstants.Preferences.UPDATE_QUOTES_AFTER_FILE_OPEN, // + Messages.PrefUpdateQuotesAfterFileOpen, getFieldEditorParent())); + + addField(new IntegerFieldEditor(UIConstants.Preferences.QUOTES_STALE_AFTER_DAYS_PATH, + "Number of days after a security price is not up-to-date anymore", + getFieldEditorParent())); + + createNoteComposite(getFieldEditorParent().getFont(), getFieldEditorParent(), // + Messages.PrefLabelNote, + "After this amount of days a security price is marked as not up-to-date.\nPlease note that only days with open trade markets are considered\n(depends on the configured calendar for each security)"); + } +} diff --git a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/dashboard/SecurityPriceTimelinessWidget.java b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/dashboard/SecurityPriceTimelinessWidget.java index 3fdac5627a..f5a9eb09ff 100644 --- a/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/dashboard/SecurityPriceTimelinessWidget.java +++ b/name.abuchen.portfolio.ui/src/name/abuchen/portfolio/ui/views/dashboard/SecurityPriceTimelinessWidget.java @@ -1,9 +1,14 @@ package name.abuchen.portfolio.ui.views.dashboard; import java.text.MessageFormat; -import java.time.LocalDate; +import java.time.Clock; +import java.util.List; import java.util.function.Supplier; +import java.util.stream.Collectors; +import javax.inject.Inject; + +import org.eclipse.e4.core.di.extensions.Preference; import org.eclipse.jface.layout.GridDataFactory; import org.eclipse.jface.layout.GridLayoutFactory; import org.eclipse.swt.SWT; @@ -12,38 +17,30 @@ import org.eclipse.swt.widgets.Label; import name.abuchen.portfolio.model.Dashboard.Widget; +import name.abuchen.portfolio.model.Security; import name.abuchen.portfolio.money.Values; import name.abuchen.portfolio.ui.Messages; import name.abuchen.portfolio.ui.UIConstants; import name.abuchen.portfolio.ui.util.Colors; import name.abuchen.portfolio.ui.util.InfoToolTip; import name.abuchen.portfolio.ui.util.swt.ColoredLabel; +import name.abuchen.portfolio.util.SecurityTimeliness; import name.abuchen.portfolio.util.TextUtil; public class SecurityPriceTimelinessWidget extends WidgetDelegate { - /** - * @see name.abuchen.portfolio.ui.views.SecuritiesTable#addColumnDateOfLatestPrice() - */ - private static final int CONSIDER_AS_OLD_AFTER_DAYS = 7; protected Label title; protected ColoredLabel indicator; - private LocalDate daysAgo; - private long oldSecuritiesCount; + private List staleSecurities; private long allSecuritiesCount; + @Preference(value = UIConstants.Preferences.QUOTES_STALE_AFTER_DAYS_PATH) + @Inject + private int numberOfTradeDaysToLookBack; + protected SecurityPriceTimelinessWidget(Widget widget, DashboardData dashboardData) { super(widget, dashboardData); - - this.daysAgo = LocalDate.now().minusDays(CONSIDER_AS_OLD_AFTER_DAYS); - - this.oldSecuritiesCount = this.getClient().getSecurities().stream() - .filter(s -> !s.isRetired() - && (s.getLatest() == null || s.getLatest().getDate().isBefore(this.daysAgo))) - .count(); - - this.allSecuritiesCount = this.getClient().getSecurities().stream().filter(s -> !s.isRetired()).count(); } @Override @@ -67,10 +64,9 @@ public Composite createControl(Composite parent, DashboardResources resources) GridDataFactory.fillDefaults().grab(true, false).applyTo(indicator); - InfoToolTip.attach(indicator, () -> { - return MessageFormat.format(Messages.TooltipSecurityPriceTimeliness, this.oldSecuritiesCount, this.allSecuritiesCount, - CONSIDER_AS_OLD_AFTER_DAYS); - }); + this.update(); + + InfoToolTip.attach(indicator, this::getTooltip); return container; } @@ -91,7 +87,30 @@ public void update(Number value) @Override public Supplier getUpdateTask() { - return () -> 1 - (double) this.oldSecuritiesCount / this.allSecuritiesCount; + this.staleSecurities = this.getClient().getSecurities().stream().filter( + s -> (new SecurityTimeliness(s, this.numberOfTradeDaysToLookBack, Clock.systemDefaultZone())) + .isStale()) + .collect(Collectors.toList()); + + this.allSecuritiesCount = this.getClient().getSecurities().stream().filter(s -> !s.isRetired()).count(); + return () -> this.allSecuritiesCount > 0 ? 1 - (double) this.staleSecurities.size() / this.allSecuritiesCount + : 0; + } + + private String getTooltip() + { + if (this.staleSecurities == null) + return ""; //$NON-NLS-1$ + + String securities = this.staleSecurities.stream() + .map(s -> s.getName() + (s.getLatest() != null + ? " (" + Values.Date.format(s.getLatest().getDate()) + ")" //$NON-NLS-1$//$NON-NLS-2$ + : "")) //$NON-NLS-1$ + .sorted().collect(Collectors.joining("\n")); //$NON-NLS-1$ + + return MessageFormat.format(Messages.TooltipSecurityPriceTimeliness, this.staleSecurities.size(), + this.allSecuritiesCount, this.numberOfTradeDaysToLookBack) + + (!securities.equals("") ? ":\n\n" + securities : ""); //$NON-NLS-1$//$NON-NLS-2$//$NON-NLS-3$ } } diff --git a/name.abuchen.portfolio/src/name/abuchen/portfolio/util/SecurityTimeliness.java b/name.abuchen.portfolio/src/name/abuchen/portfolio/util/SecurityTimeliness.java new file mode 100644 index 0000000000..16b1e79075 --- /dev/null +++ b/name.abuchen.portfolio/src/name/abuchen/portfolio/util/SecurityTimeliness.java @@ -0,0 +1,48 @@ +package name.abuchen.portfolio.util; + +import java.time.Clock; +import java.time.LocalDate; + +import name.abuchen.portfolio.model.Security; + +public final class SecurityTimeliness +{ + private Security security; + private TradeCalendar tradeCalendar; + private Clock clock; + private int numberOfTradeDaysToLookBack; + + public SecurityTimeliness(Security security, int numberOfTradeDaysToLookBack, Clock clock) + { + this.security = security; + this.numberOfTradeDaysToLookBack = numberOfTradeDaysToLookBack; + this.clock = clock; + this.tradeCalendar = TradeCalendarManager.getInstance(security); + } + + public boolean isStale() + { + final LocalDate daysAgo = this.getStartDate(); + + return !this.security.isRetired() + && (this.security.getLatest() == null || this.security.getLatest().getDate().isBefore(daysAgo)); + } + + private LocalDate getStartDate() + { + LocalDate currentDay = LocalDate.now(this.clock); + while (this.numberOfTradeDaysToLookBack > 0) + { + currentDay = currentDay.minusDays(1); + + if (this.tradeCalendar.isHoliday(currentDay) || this.tradeCalendar.isWeekend(currentDay)) + { + continue; + } + + numberOfTradeDaysToLookBack--; + } + + return currentDay; + } +}