diff --git a/common/src/main/java/bisq/common/util/CompletableFutureUtils.java b/common/src/main/java/bisq/common/util/CompletableFutureUtils.java new file mode 100644 index 00000000000..6f26ed3a455 --- /dev/null +++ b/common/src/main/java/bisq/common/util/CompletableFutureUtils.java @@ -0,0 +1,39 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.common.util; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +public class CompletableFutureUtils { + /** + * @param list List of futures + * @param The generic type of the future + * @return Returns a CompletableFuture with a list of the futures we got as parameter once all futures + * are completed (incl. exceptionally completed). + */ + public static CompletableFuture> allOf(List> list) { + CompletableFuture allFuturesResult = CompletableFuture.allOf(list.toArray(new CompletableFuture[list.size()])); + return allFuturesResult.thenApply(v -> + list.stream(). + map(CompletableFuture::join). + collect(Collectors.toList()) + ); + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/market/trades/ChartCalculations.java b/desktop/src/main/java/bisq/desktop/main/market/trades/ChartCalculations.java new file mode 100644 index 00000000000..3c720ee48d9 --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/market/trades/ChartCalculations.java @@ -0,0 +1,303 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.desktop.main.market.trades; + +import bisq.desktop.main.market.trades.charts.CandleData; +import bisq.desktop.util.DisplayUtils; + +import bisq.core.locale.CurrencyUtil; +import bisq.core.monetary.Altcoin; +import bisq.core.trade.statistics.TradeStatistics3; + +import bisq.common.util.MathUtils; + +import org.bitcoinj.core.Coin; + +import com.google.common.annotations.VisibleForTesting; + +import javafx.scene.chart.XYChart; + +import javafx.util.Pair; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import lombok.Getter; + +import static bisq.desktop.main.market.trades.TradesChartsViewModel.MAX_TICKS; + +public class ChartCalculations { + static final ZoneId ZONE_ID = ZoneId.systemDefault(); + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Async + /////////////////////////////////////////////////////////////////////////////////////////// + + static CompletableFuture>> getUsdAveragePriceMapsPerTickUnit(Set tradeStatisticsSet) { + return CompletableFuture.supplyAsync(() -> { + Map> usdAveragePriceMapsPerTickUnit = new HashMap<>(); + Map>> dateMapsPerTickUnit = new HashMap<>(); + for (TradesChartsViewModel.TickUnit tick : TradesChartsViewModel.TickUnit.values()) { + dateMapsPerTickUnit.put(tick, new HashMap<>()); + } + + tradeStatisticsSet.stream() + .filter(e -> e.getCurrency().equals("USD")) + .forEach(tradeStatistics -> { + for (TradesChartsViewModel.TickUnit tick : TradesChartsViewModel.TickUnit.values()) { + long time = roundToTick(tradeStatistics.getLocalDateTime(), tick).getTime(); + Map> map = dateMapsPerTickUnit.get(tick); + map.putIfAbsent(time, new ArrayList<>()); + map.get(time).add(tradeStatistics); + } + }); + + dateMapsPerTickUnit.forEach((tick, map) -> { + HashMap priceMap = new HashMap<>(); + map.forEach((date, tradeStatisticsList) -> priceMap.put(date, getAveragePrice(tradeStatisticsList))); + usdAveragePriceMapsPerTickUnit.put(tick, priceMap); + }); + return usdAveragePriceMapsPerTickUnit; + }); + } + + static CompletableFuture> getTradeStatisticsForCurrency(Set tradeStatisticsSet, + String currencyCode, + boolean showAllTradeCurrencies) { + return CompletableFuture.supplyAsync(() -> { + return tradeStatisticsSet.stream() + .filter(e -> showAllTradeCurrencies || e.getCurrency().equals(currencyCode)) + .collect(Collectors.toList()); + }); + } + + static CompletableFuture getUpdateChartResult(List tradeStatisticsByCurrency, + TradesChartsViewModel.TickUnit tickUnit, + Map> usdAveragePriceMapsPerTickUnit, + String currencyCode) { + return CompletableFuture.supplyAsync(() -> { + // Generate date range and create sets for all ticks + Map>> itemsPerInterval = getItemsPerInterval(tradeStatisticsByCurrency, tickUnit); + + Map usdAveragePriceMap = usdAveragePriceMapsPerTickUnit.get(tickUnit); + AtomicLong averageUsdPrice = new AtomicLong(0); + + // create CandleData for defined time interval + List candleDataList = itemsPerInterval.entrySet().stream() + .filter(entry -> entry.getKey() >= 0 && !entry.getValue().getValue().isEmpty()) + .map(entry -> { + long tickStartDate = entry.getValue().getKey().getTime(); + // If we don't have a price we take the previous one + if (usdAveragePriceMap.containsKey(tickStartDate)) { + averageUsdPrice.set(usdAveragePriceMap.get(tickStartDate)); + } + return getCandleData(entry.getKey(), entry.getValue().getValue(), averageUsdPrice.get(), tickUnit, currencyCode, itemsPerInterval); + }) + .sorted(Comparator.comparingLong(o -> o.tick)) + .collect(Collectors.toList()); + + List> priceItems = candleDataList.stream() + .map(e -> new XYChart.Data(e.tick, e.open, e)) + .collect(Collectors.toList()); + + List> volumeItems = candleDataList.stream() + .map(candleData -> new XYChart.Data(candleData.tick, candleData.accumulatedAmount, candleData)) + .collect(Collectors.toList()); + + List> volumeInUsdItems = candleDataList.stream() + .map(candleData -> new XYChart.Data(candleData.tick, candleData.volumeInUsd, candleData)) + .collect(Collectors.toList()); + + return new UpdateChartResult(itemsPerInterval, priceItems, volumeItems, volumeInUsdItems); + }); + } + + @Getter + static class UpdateChartResult { + private final Map>> itemsPerInterval; + private final List> priceItems; + private final List> volumeItems; + private final List> volumeInUsdItems; + + public UpdateChartResult(Map>> itemsPerInterval, + List> priceItems, + List> volumeItems, + List> volumeInUsdItems) { + + this.itemsPerInterval = itemsPerInterval; + this.priceItems = priceItems; + this.volumeItems = volumeItems; + this.volumeInUsdItems = volumeInUsdItems; + } + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Private + /////////////////////////////////////////////////////////////////////////////////////////// + + static Map>> getItemsPerInterval(List tradeStatisticsByCurrency, + TradesChartsViewModel.TickUnit tickUnit) { + // Generate date range and create sets for all ticks + Map>> itemsPerInterval = new HashMap<>(); + Date time = new Date(); + for (long i = MAX_TICKS + 1; i >= 0; --i) { + Pair> pair = new Pair<>((Date) time.clone(), new HashSet<>()); + itemsPerInterval.put(i, pair); + // We adjust the time for the next iteration + time.setTime(time.getTime() - 1); + time = roundToTick(time, tickUnit); + } + + // Get all entries for the defined time interval + tradeStatisticsByCurrency.forEach(tradeStatistics -> { + for (long i = MAX_TICKS; i > 0; --i) { + Pair> pair = itemsPerInterval.get(i); + if (tradeStatistics.getDate().after(pair.getKey())) { + pair.getValue().add(tradeStatistics); + break; + } + } + }); + return itemsPerInterval; + } + + + static Date roundToTick(LocalDateTime localDate, TradesChartsViewModel.TickUnit tickUnit) { + switch (tickUnit) { + case YEAR: + return Date.from(localDate.withMonth(1).withDayOfYear(1).withHour(0).withMinute(0).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); + case MONTH: + return Date.from(localDate.withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); + case WEEK: + int dayOfWeek = localDate.getDayOfWeek().getValue(); + LocalDateTime firstDayOfWeek = ChronoUnit.DAYS.addTo(localDate, 1 - dayOfWeek); + return Date.from(firstDayOfWeek.withHour(0).withMinute(0).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); + case DAY: + return Date.from(localDate.withHour(0).withMinute(0).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); + case HOUR: + return Date.from(localDate.withMinute(0).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); + case MINUTE_10: + return Date.from(localDate.withMinute(localDate.getMinute() - localDate.getMinute() % 10).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); + default: + return Date.from(localDate.atZone(ZONE_ID).toInstant()); + } + } + + static Date roundToTick(Date time, TradesChartsViewModel.TickUnit tickUnit) { + return roundToTick(time.toInstant().atZone(ChartCalculations.ZONE_ID).toLocalDateTime(), tickUnit); + } + + private static long getAveragePrice(List tradeStatisticsList) { + long accumulatedAmount = 0; + long accumulatedVolume = 0; + for (TradeStatistics3 tradeStatistics : tradeStatisticsList) { + accumulatedAmount += tradeStatistics.getAmount(); + accumulatedVolume += tradeStatistics.getTradeVolume().getValue(); + } + + double accumulatedVolumeAsDouble = MathUtils.scaleUpByPowerOf10((double) accumulatedVolume, Coin.SMALLEST_UNIT_EXPONENT); + return MathUtils.roundDoubleToLong(accumulatedVolumeAsDouble / (double) accumulatedAmount); + } + + @VisibleForTesting + static CandleData getCandleData(long tick, Set set, + long averageUsdPrice, + TradesChartsViewModel.TickUnit tickUnit, + String currencyCode, + Map>> itemsPerInterval) { + long open = 0; + long close = 0; + long high = 0; + long low = 0; + long accumulatedVolume = 0; + long accumulatedAmount = 0; + long numTrades = set.size(); + List tradePrices = new ArrayList<>(); + for (TradeStatistics3 item : set) { + long tradePriceAsLong = item.getTradePrice().getValue(); + // Previously a check was done which inverted the low and high for cryptocurrencies. + low = (low != 0) ? Math.min(low, tradePriceAsLong) : tradePriceAsLong; + high = (high != 0) ? Math.max(high, tradePriceAsLong) : tradePriceAsLong; + + accumulatedVolume += item.getTradeVolume().getValue(); + accumulatedAmount += item.getTradeAmount().getValue(); + tradePrices.add(tradePriceAsLong); + } + Collections.sort(tradePrices); + + List list = new ArrayList<>(set); + list.sort(Comparator.comparingLong(TradeStatistics3::getDateAsLong)); + if (list.size() > 0) { + open = list.get(0).getTradePrice().getValue(); + close = list.get(list.size() - 1).getTradePrice().getValue(); + } + + long averagePrice; + Long[] prices = new Long[tradePrices.size()]; + tradePrices.toArray(prices); + long medianPrice = MathUtils.getMedian(prices); + boolean isBullish; + if (CurrencyUtil.isCryptoCurrency(currencyCode)) { + isBullish = close < open; + double accumulatedAmountAsDouble = MathUtils.scaleUpByPowerOf10((double) accumulatedAmount, Altcoin.SMALLEST_UNIT_EXPONENT); + averagePrice = MathUtils.roundDoubleToLong(accumulatedAmountAsDouble / (double) accumulatedVolume); + } else { + isBullish = close > open; + double accumulatedVolumeAsDouble = MathUtils.scaleUpByPowerOf10((double) accumulatedVolume, Coin.SMALLEST_UNIT_EXPONENT); + averagePrice = MathUtils.roundDoubleToLong(accumulatedVolumeAsDouble / (double) accumulatedAmount); + } + + Date dateFrom = new Date(getTimeFromTickIndex(tick, itemsPerInterval)); + Date dateTo = new Date(getTimeFromTickIndex(tick + 1, itemsPerInterval)); + String dateString = tickUnit.ordinal() > TradesChartsViewModel.TickUnit.DAY.ordinal() ? + DisplayUtils.formatDateTimeSpan(dateFrom, dateTo) : + DisplayUtils.formatDate(dateFrom) + " - " + DisplayUtils.formatDate(dateTo); + + // We do not need precision, so we scale down before multiplication otherwise we could get an overflow. + averageUsdPrice = (long) MathUtils.scaleDownByPowerOf10((double) averageUsdPrice, 4); + long volumeInUsd = averageUsdPrice * (long) MathUtils.scaleDownByPowerOf10((double) accumulatedAmount, 4); + // We store USD value without decimals as its only total volume, no precision is needed. + volumeInUsd = (long) MathUtils.scaleDownByPowerOf10((double) volumeInUsd, 4); + return new CandleData(tick, open, close, high, low, averagePrice, medianPrice, accumulatedAmount, accumulatedVolume, + numTrades, isBullish, dateString, volumeInUsd); + } + + static long getTimeFromTickIndex(long tick, Map>> itemsPerInterval) { + if (tick > MAX_TICKS + 1 || + itemsPerInterval.get(tick) == null) { + return 0; + } + return itemsPerInterval.get(tick).getKey().getTime(); + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/market/trades/TradesChartsView.java b/desktop/src/main/java/bisq/desktop/main/market/trades/TradesChartsView.java index 3c7d2501404..46cb807607f 100644 --- a/desktop/src/main/java/bisq/desktop/main/market/trades/TradesChartsView.java +++ b/desktop/src/main/java/bisq/desktop/main/market/trades/TradesChartsView.java @@ -103,11 +103,12 @@ import java.util.Comparator; import java.util.Date; import java.util.List; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import org.jetbrains.annotations.NotNull; +import static bisq.desktop.main.market.trades.TradesChartsViewModel.MAX_TICKS; import static bisq.desktop.util.FormBuilder.addTopLabelAutocompleteComboBox; import static bisq.desktop.util.FormBuilder.getTopLabelWithVBox; @@ -161,8 +162,7 @@ private CurrencyListItem specialShowAllItem() { private SingleSelectionModel tabPaneSelectionModel; private TableColumn priceColumn, volumeColumn, marketColumn; - private final ObservableList listItems = FXCollections.observableArrayList(); - private final SortedList sortedList = new SortedList<>(listItems); + private SortedList sortedList = new SortedList<>(FXCollections.observableArrayList()); private ChangeListener timeUnitChangeListener; private ChangeListener priceAxisYWidthListener; @@ -242,7 +242,7 @@ public void initialize() { layoutChart(); }; tradeStatisticsByCurrencyListener = c -> { - nrOfTradeStatisticsLabel.setText(Res.get("market.trades.nrOfTrades", model.selectedTradeStatistics.size())); + nrOfTradeStatisticsLabel.setText(Res.get("market.trades.nrOfTrades", model.tradeStatisticsByCurrency.size())); fillList(); }; parentHeightListener = (observable, oldValue, newValue) -> layout(); @@ -282,12 +282,10 @@ public void initialize() { layout(); return null; }); - } @Override protected void activate() { - // root.getParent() is null at initialize tabPaneSelectionModel = GUIUtil.getParentOfType(root, JFXTabPane.class).getSelectionModel(); selectedTabIndexListener = (observable, oldValue, newValue) -> model.setSelectedTabIndex((int) newValue); model.setSelectedTabIndex(tabPaneSelectionModel.getSelectedIndex()); @@ -320,15 +318,13 @@ else if (model.getSelectedCurrencyListItem().isPresent()) toggleGroup.selectedToggleProperty().addListener(timeUnitChangeListener); priceAxisY.widthProperty().addListener(priceAxisYWidthListener); volumeAxisY.widthProperty().addListener(volumeAxisYWidthListener); - model.selectedTradeStatistics.addListener(tradeStatisticsByCurrencyListener); + model.tradeStatisticsByCurrency.addListener(tradeStatisticsByCurrencyListener); priceAxisY.labelProperty().bind(priceColumnLabel); priceColumnLabel.addListener(priceColumnLabelListener); currencySelectionSubscriber = currencySelectionBinding.subscribe((observable, oldValue, newValue) -> { }); - sortedList.comparatorProperty().bind(tableView.comparatorProperty()); - boolean useAnimations = model.preferences.isUseAnimations(); priceChart.setAnimated(useAnimations); volumeChart.setAnimated(useAnimations); @@ -337,10 +333,9 @@ else if (model.getSelectedCurrencyListItem().isPresent()) volumeAxisX.setTickLabelFormatter(getTimeAxisStringConverter()); volumeInUsdAxisX.setTickLabelFormatter(getTimeAxisStringConverter()); - nrOfTradeStatisticsLabel.setText(Res.get("market.trades.nrOfTrades", model.selectedTradeStatistics.size())); + nrOfTradeStatisticsLabel.setText(Res.get("market.trades.nrOfTrades", model.tradeStatisticsByCurrency.size())); exportLink.setOnAction(e -> exportToCsv()); - UserThread.runAfter(this::updateChartData, 100, TimeUnit.MILLISECONDS); if (root.getParent() instanceof Pane) { rootParent = (Pane) root.getParent(); @@ -358,8 +353,6 @@ else if (model.getSelectedCurrencyListItem().isPresent()) user.requestPersistence(); }); - fillList(); - tableView.setItems(sortedList); layout(); } @@ -370,7 +363,7 @@ protected void deactivate() { toggleGroup.selectedToggleProperty().removeListener(timeUnitChangeListener); priceAxisY.widthProperty().removeListener(priceAxisYWidthListener); volumeAxisY.widthProperty().removeListener(volumeAxisYWidthListener); - model.selectedTradeStatistics.removeListener(tradeStatisticsByCurrencyListener); + model.tradeStatisticsByCurrency.removeListener(tradeStatisticsByCurrencyListener); priceAxisY.labelProperty().unbind(); priceColumnLabel.removeListener(priceColumnLabelListener); @@ -398,12 +391,27 @@ private void showVolumeAsUsd(Boolean showUsd) { } private void fillList() { - List tradeStatistics3ListItems = model.selectedTradeStatistics.stream() - .map(tradeStatistics -> new TradeStatistics3ListItem(tradeStatistics, - coinFormatter, - model.showAllTradeCurrenciesProperty.get())) - .collect(Collectors.toList()); - listItems.setAll(tradeStatistics3ListItems); + long ts = System.currentTimeMillis(); + CompletableFuture.supplyAsync(() -> { + return model.tradeStatisticsByCurrency.stream() + .map(tradeStatistics -> new TradeStatistics3ListItem(tradeStatistics, + coinFormatter, + model.showAllTradeCurrenciesProperty.get())) + .collect(Collectors.toCollection(FXCollections::observableArrayList)); + }).whenComplete((listItems, throwable) -> { + log.debug("Creating listItems took {} ms", System.currentTimeMillis() - ts); + + long ts2 = System.currentTimeMillis(); + sortedList.comparatorProperty().unbind(); + // Sorting is slow as we have > 100k items. So we prefer to do it on the non UI thread. + sortedList = new SortedList<>(listItems); + sortedList.comparatorProperty().bind(tableView.comparatorProperty()); + log.debug("Created sorted list took {} ms", System.currentTimeMillis() - ts2); + UserThread.execute(() -> { + // When we attach the list to the table we need to be on the UI thread. + tableView.setItems(sortedList); + }); + }); } private void exportToCsv() { @@ -460,7 +468,7 @@ private void exportToCsv() { private void createCharts() { priceSeries = new XYChart.Series<>(); - priceAxisX = new NumberAxis(0, model.maxTicks + 1, 1); + priceAxisX = new NumberAxis(0, MAX_TICKS + 1, 1); priceAxisX.setTickUnit(4); priceAxisX.setMinorTickCount(4); priceAxisX.setMinorTickVisible(true); @@ -526,11 +534,11 @@ public Number fromString(String string) { priceChartPane.getChildren().add(priceChart); - volumeAxisX = new NumberAxis(0, model.maxTicks + 1, 1); + volumeAxisX = new NumberAxis(0, MAX_TICKS + 1, 1); volumeAxisY = new NumberAxis(); volumeChart = getVolumeChart(volumeAxisX, volumeAxisY, volumeSeries, "BTC"); - volumeInUsdAxisX = new NumberAxis(0, model.maxTicks + 1, 1); + volumeInUsdAxisX = new NumberAxis(0, MAX_TICKS + 1, 1); NumberAxis volumeInUsdAxisY = new NumberAxis(); volumeInUsdChart = getVolumeChart(volumeInUsdAxisX, volumeInUsdAxisY, volumeInUsdSeries, "USD"); volumeInUsdChart.setVisible(false); @@ -637,7 +645,7 @@ public String toString(Number object) { long index = MathUtils.doubleToLong((double) object); // The last tick is on the chart edge, it is not well spaced with // the previous tick and interferes with its label. - if (model.maxTicks + 1 == index) return ""; + if (MAX_TICKS + 1 == index) return ""; long time = model.getTimeFromTickIndex(index); String fmt = ""; diff --git a/desktop/src/main/java/bisq/desktop/main/market/trades/TradesChartsViewModel.java b/desktop/src/main/java/bisq/desktop/main/market/trades/TradesChartsViewModel.java index b56073f48ba..ff44daccb06 100644 --- a/desktop/src/main/java/bisq/desktop/main/market/trades/TradesChartsViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/market/trades/TradesChartsViewModel.java @@ -20,32 +20,26 @@ import bisq.desktop.Navigation; import bisq.desktop.common.model.ActivatableViewModel; import bisq.desktop.main.MainView; -import bisq.desktop.main.market.trades.charts.CandleData; import bisq.desktop.main.settings.SettingsView; import bisq.desktop.main.settings.preferences.PreferencesView; import bisq.desktop.util.CurrencyList; import bisq.desktop.util.CurrencyListItem; -import bisq.desktop.util.DisplayUtils; import bisq.desktop.util.GUIUtil; import bisq.core.locale.CryptoCurrency; import bisq.core.locale.CurrencyUtil; import bisq.core.locale.GlobalSettings; import bisq.core.locale.TradeCurrency; -import bisq.core.monetary.Altcoin; import bisq.core.provider.price.PriceFeedService; import bisq.core.trade.statistics.TradeStatistics3; import bisq.core.trade.statistics.TradeStatisticsManager; import bisq.core.user.Preferences; -import bisq.common.util.MathUtils; - -import org.bitcoinj.core.Coin; +import bisq.common.UserThread; +import bisq.common.util.CompletableFutureUtils; import com.google.inject.Inject; -import com.google.common.annotations.VisibleForTesting; - import javafx.scene.chart.XYChart; import javafx.beans.property.BooleanProperty; @@ -59,30 +53,21 @@ import javafx.util.Pair; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.temporal.ChronoUnit; - import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; import java.util.Date; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; -import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import javax.annotation.Nullable; class TradesChartsViewModel extends ActivatableViewModel { - + static final int MAX_TICKS = 90; private static final int TAB_INDEX = 2; - private static final ZoneId ZONE_ID = ZoneId.systemDefault(); - /////////////////////////////////////////////////////////////////////////////////////////// // Enum @@ -107,17 +92,18 @@ public enum TickUnit { final BooleanProperty showAllTradeCurrenciesProperty = new SimpleBooleanProperty(false); private final CurrencyList currencyListItems; private final CurrencyListItem showAllCurrencyListItem = new CurrencyListItem(new CryptoCurrency(GUIUtil.SHOW_ALL_FLAG, ""), -1); - final ObservableList selectedTradeStatistics = FXCollections.observableArrayList(); + final ObservableList tradeStatisticsByCurrency = FXCollections.observableArrayList(); final ObservableList> priceItems = FXCollections.observableArrayList(); final ObservableList> volumeItems = FXCollections.observableArrayList(); final ObservableList> volumeInUsdItems = FXCollections.observableArrayList(); - private Map>> itemsPerInterval; + private final Map>> itemsPerInterval = new HashMap<>(); TickUnit tickUnit; - final int maxTicks = 90; private int selectedTabIndex; - final Map> usdPriceMapsPerTickUnit = new HashMap<>(); + final Map> usdAveragePriceMapsPerTickUnit = new HashMap<>(); private boolean fillTradeCurrenciesOnActivateCalled; + private volatile boolean deactivateCalled; + /////////////////////////////////////////////////////////////////////////////////////////// // Constructor, lifecycle @@ -132,8 +118,17 @@ public enum TickUnit { this.navigation = navigation; setChangeListener = change -> { - updateSelectedTradeStatistics(getCurrencyCode()); - updateChartData(); + applyAsyncTradeStatisticsForCurrency(getCurrencyCode()) + .whenComplete((result, throwable) -> { + if (deactivateCalled) { + return; + } + if (throwable != null) { + log.error("Error at setChangeListener/applyAsyncTradeStatisticsForCurrency. {}", throwable.toString()); + return; + } + applyAsyncChartData(); + }); fillTradeCurrencies(); }; @@ -151,23 +146,149 @@ public enum TickUnit { @Override protected void activate() { + long ts = System.currentTimeMillis(); + deactivateCalled = false; + tradeStatisticsManager.getObservableTradeStatisticsSet().addListener(setChangeListener); if (!fillTradeCurrenciesOnActivateCalled) { fillTradeCurrencies(); fillTradeCurrenciesOnActivateCalled = true; } - buildUsdPricesPerDay(); - updateSelectedTradeStatistics(getCurrencyCode()); - updateChartData(); syncPriceFeedCurrency(); setMarketPriceFeedCurrency(); + + List> allFutures = new ArrayList<>(); + CompletableFuture task1Done = new CompletableFuture<>(); + allFutures.add(task1Done); + CompletableFuture task2Done = new CompletableFuture<>(); + allFutures.add(task2Done); + CompletableFutureUtils.allOf(allFutures) + .whenComplete((res, throwable) -> { + if (deactivateCalled) { + return; + } + if (throwable != null) { + log.error(throwable.toString()); + return; + } + //Once applyAsyncUsdAveragePriceMapsPerTickUnit and applyAsyncTradeStatisticsForCurrency are + // both completed we call applyAsyncChartData + UserThread.execute(this::applyAsyncChartData); + }); + + // We call applyAsyncUsdAveragePriceMapsPerTickUnit and applyAsyncTradeStatisticsForCurrency + // in parallel for better performance + applyAsyncUsdAveragePriceMapsPerTickUnit(task1Done); + applyAsyncTradeStatisticsForCurrency(getCurrencyCode(), task2Done); + + log.debug("activate took {}", System.currentTimeMillis() - ts); } @Override protected void deactivate() { + deactivateCalled = true; tradeStatisticsManager.getObservableTradeStatisticsSet().removeListener(setChangeListener); + + // We want to avoid to trigger listeners in the view so we delay a bit. Deactivate on model is called before + // deactivate on view. + UserThread.execute(() -> { + usdAveragePriceMapsPerTickUnit.clear(); + tradeStatisticsByCurrency.clear(); + priceItems.clear(); + volumeItems.clear(); + volumeInUsdItems.clear(); + itemsPerInterval.clear(); + }); } + + /////////////////////////////////////////////////////////////////////////////////////////// + // Async calls + /////////////////////////////////////////////////////////////////////////////////////////// + + private void applyAsyncUsdAveragePriceMapsPerTickUnit(CompletableFuture completeFuture) { + long ts = System.currentTimeMillis(); + ChartCalculations.getUsdAveragePriceMapsPerTickUnit(tradeStatisticsManager.getObservableTradeStatisticsSet()) + .whenComplete((usdAveragePriceMapsPerTickUnit, throwable) -> { + if (deactivateCalled) { + return; + } + if (throwable != null) { + log.error("Error at applyAsyncUsdAveragePriceMapsPerTickUnit. {}", throwable.toString()); + completeFuture.completeExceptionally(throwable); + return; + } + UserThread.execute(() -> { + this.usdAveragePriceMapsPerTickUnit.clear(); + this.usdAveragePriceMapsPerTickUnit.putAll(usdAveragePriceMapsPerTickUnit); + log.debug("applyAsyncUsdAveragePriceMapsPerTickUnit took {}", System.currentTimeMillis() - ts); + completeFuture.complete(true); + }); + }); + } + + private CompletableFuture applyAsyncTradeStatisticsForCurrency(String currencyCode) { + return applyAsyncTradeStatisticsForCurrency(currencyCode, null); + } + + private CompletableFuture applyAsyncTradeStatisticsForCurrency(String currencyCode, + @Nullable CompletableFuture completeFuture) { + CompletableFuture future = new CompletableFuture<>(); + long ts = System.currentTimeMillis(); + ChartCalculations.getTradeStatisticsForCurrency(tradeStatisticsManager.getObservableTradeStatisticsSet(), + currencyCode, + showAllTradeCurrenciesProperty.get()) + .whenComplete((list, throwable) -> { + if (deactivateCalled) { + return; + } + if (throwable != null) { + log.error("Error at applyAsyncTradeStatisticsForCurrency. {}", throwable.toString()); + if (completeFuture != null) { + completeFuture.completeExceptionally(throwable); + } + return; + } + + UserThread.execute(() -> { + tradeStatisticsByCurrency.setAll(list); + log.debug("applyAsyncTradeStatisticsForCurrency took {}", System.currentTimeMillis() - ts); + if (completeFuture != null) { + completeFuture.complete(true); + } + future.complete(true); + }); + }); + return future; + } + + private void applyAsyncChartData() { + long ts = System.currentTimeMillis(); + ChartCalculations.getUpdateChartResult(new ArrayList<>(tradeStatisticsByCurrency), + tickUnit, + usdAveragePriceMapsPerTickUnit, + getCurrencyCode()) + .whenComplete((updateChartResult, throwable) -> { + if (deactivateCalled) { + return; + } + if (throwable != null) { + log.error("Error at applyAsyncChartData. {}", throwable.toString()); + return; + } + UserThread.execute(() -> { + itemsPerInterval.clear(); + itemsPerInterval.putAll(updateChartResult.getItemsPerInterval()); + + priceItems.setAll(updateChartResult.getPriceItems()); + volumeItems.setAll(updateChartResult.getVolumeItems()); + volumeInUsdItems.setAll(updateChartResult.getVolumeInUsdItems()); + log.debug("applyAsyncChartData took {}", System.currentTimeMillis() - ts); + }); + }); + } + + /////////////////////////////////////////////////////////////////////////////////////////// // UI actions /////////////////////////////////////////////////////////////////////////////////////////// @@ -192,15 +313,24 @@ void onSetTradeCurrency(TradeCurrency tradeCurrency) { } preferences.setTradeChartsScreenCurrencyCode(code); - updateSelectedTradeStatistics(getCurrencyCode()); - updateChartData(); + applyAsyncTradeStatisticsForCurrency(getCurrencyCode()) + .whenComplete((result, throwable) -> { + if (deactivateCalled) { + return; + } + if (throwable != null) { + log.error("Error at onSetTradeCurrency/applyAsyncTradeStatisticsForCurrency. {}", throwable.toString()); + return; + } + applyAsyncChartData(); + }); } } void setTickUnit(TickUnit tickUnit) { this.tickUnit = tickUnit; preferences.setTradeStatisticsTickUnitIndex(tickUnit.ordinal()); - updateChartData(); + applyAsyncChartData(); } void setSelectedTabIndex(int selectedTabIndex) { @@ -209,6 +339,7 @@ void setSelectedTabIndex(int selectedTabIndex) { setMarketPriceFeedCurrency(); } + /////////////////////////////////////////////////////////////////////////////////////////// // Getters /////////////////////////////////////////////////////////////////////////////////////////// @@ -225,6 +356,11 @@ public Optional getSelectedCurrencyListItem() { return currencyListItems.getObservableList().stream().filter(e -> e.tradeCurrency.equals(selectedTradeCurrencyProperty.get())).findAny(); } + long getTimeFromTickIndex(long tick) { + return ChartCalculations.getTimeFromTickIndex(tick, itemsPerInterval); + } + + /////////////////////////////////////////////////////////////////////////////////////////// // Private /////////////////////////////////////////////////////////////////////////////////////////// @@ -234,7 +370,6 @@ private void fillTradeCurrencies() { List tradeCurrencyList = tradeStatisticsManager.getObservableTradeStatisticsSet().stream() .flatMap(e -> CurrencyUtil.getTradeCurrency(e.getCurrency()).stream()) .collect(Collectors.toList()); - currencyListItems.updateWithCurrencies(tradeCurrencyList, showAllCurrencyListItem); } @@ -252,197 +387,6 @@ private void syncPriceFeedCurrency() { priceFeedService.setCurrencyCode(selectedTradeCurrencyProperty.get().getCode()); } - private void buildUsdPricesPerDay() { - if (usdPriceMapsPerTickUnit.isEmpty()) { - Map>> dateMapsPerTickUnit = new HashMap<>(); - for (TickUnit tick : TickUnit.values()) { - dateMapsPerTickUnit.put(tick, new HashMap<>()); - } - - tradeStatisticsManager.getObservableTradeStatisticsSet().stream() - .filter(e -> e.getCurrency().equals("USD")) - .forEach(tradeStatistics -> { - for (TickUnit tick : TickUnit.values()) { - long time = roundToTick(tradeStatistics.getLocalDateTime(), tick).getTime(); - Map> map = dateMapsPerTickUnit.get(tick); - map.putIfAbsent(time, new ArrayList<>()); - map.get(time).add(tradeStatistics); - } - }); - - dateMapsPerTickUnit.forEach((tick, map) -> { - HashMap priceMap = new HashMap<>(); - map.forEach((date, tradeStatisticsList) -> priceMap.put(date, getAveragePrice(tradeStatisticsList))); - usdPriceMapsPerTickUnit.put(tick, priceMap); - }); - } - } - - private long getAveragePrice(List tradeStatisticsList) { - long accumulatedAmount = 0; - long accumulatedVolume = 0; - for (TradeStatistics3 tradeStatistics : tradeStatisticsList) { - accumulatedAmount += tradeStatistics.getAmount(); - accumulatedVolume += tradeStatistics.getTradeVolume().getValue(); - } - - double accumulatedVolumeAsDouble = MathUtils.scaleUpByPowerOf10((double) accumulatedVolume, Coin.SMALLEST_UNIT_EXPONENT); - return MathUtils.roundDoubleToLong(accumulatedVolumeAsDouble / (double) accumulatedAmount); - } - - private void updateChartData() { - // Generate date range and create sets for all ticks - itemsPerInterval = new HashMap<>(); - Date time = new Date(); - for (long i = maxTicks + 1; i >= 0; --i) { - Pair> pair = new Pair<>((Date) time.clone(), new HashSet<>()); - itemsPerInterval.put(i, pair); - // We adjust the time for the next iteration - time.setTime(time.getTime() - 1); - time = roundToTick(time, tickUnit); - } - - // Get all entries for the defined time interval - selectedTradeStatistics.forEach(tradeStatistics -> { - for (long i = maxTicks; i > 0; --i) { - Pair> pair = itemsPerInterval.get(i); - if (tradeStatistics.getDate().after(pair.getKey())) { - pair.getValue().add(tradeStatistics); - break; - } - } - }); - - Map map = usdPriceMapsPerTickUnit.get(tickUnit); - AtomicLong averageUsdPrice = new AtomicLong(0); - - // create CandleData for defined time interval - List candleDataList = itemsPerInterval.entrySet().stream() - .filter(entry -> entry.getKey() >= 0 && !entry.getValue().getValue().isEmpty()) - .map(entry -> { - long tickStartDate = entry.getValue().getKey().getTime(); - // If we don't have a price we take the previous one - if (map.containsKey(tickStartDate)) { - averageUsdPrice.set(map.get(tickStartDate)); - } - return getCandleData(entry.getKey(), entry.getValue().getValue(), averageUsdPrice.get()); - }) - .sorted(Comparator.comparingLong(o -> o.tick)) - .collect(Collectors.toList()); - - priceItems.setAll(candleDataList.stream() - .map(e -> new XYChart.Data(e.tick, e.open, e)) - .collect(Collectors.toList())); - - volumeItems.setAll(candleDataList.stream() - .map(candleData -> new XYChart.Data(candleData.tick, candleData.accumulatedAmount, candleData)) - .collect(Collectors.toList())); - - volumeInUsdItems.setAll(candleDataList.stream() - .map(candleData -> new XYChart.Data(candleData.tick, candleData.volumeInUsd, candleData)) - .collect(Collectors.toList())); - } - - private void updateSelectedTradeStatistics(String currencyCode) { - selectedTradeStatistics.setAll(tradeStatisticsManager.getObservableTradeStatisticsSet().stream() - .filter(e -> showAllTradeCurrenciesProperty.get() || e.getCurrency().equals(currencyCode)) - .collect(Collectors.toList())); - } - - @VisibleForTesting - CandleData getCandleData(long tick, Set set, long averageUsdPrice) { - long open = 0; - long close = 0; - long high = 0; - long low = 0; - long accumulatedVolume = 0; - long accumulatedAmount = 0; - long numTrades = set.size(); - List tradePrices = new ArrayList<>(); - for (TradeStatistics3 item : set) { - long tradePriceAsLong = item.getTradePrice().getValue(); - // Previously a check was done which inverted the low and high for cryptocurrencies. - low = (low != 0) ? Math.min(low, tradePriceAsLong) : tradePriceAsLong; - high = (high != 0) ? Math.max(high, tradePriceAsLong) : tradePriceAsLong; - - accumulatedVolume += item.getTradeVolume().getValue(); - accumulatedAmount += item.getTradeAmount().getValue(); - tradePrices.add(tradePriceAsLong); - } - Collections.sort(tradePrices); - - List list = new ArrayList<>(set); - list.sort(Comparator.comparingLong(TradeStatistics3::getDateAsLong)); - if (list.size() > 0) { - open = list.get(0).getTradePrice().getValue(); - close = list.get(list.size() - 1).getTradePrice().getValue(); - } - - long averagePrice; - Long[] prices = new Long[tradePrices.size()]; - tradePrices.toArray(prices); - long medianPrice = MathUtils.getMedian(prices); - boolean isBullish; - if (CurrencyUtil.isCryptoCurrency(getCurrencyCode())) { - isBullish = close < open; - double accumulatedAmountAsDouble = MathUtils.scaleUpByPowerOf10((double) accumulatedAmount, Altcoin.SMALLEST_UNIT_EXPONENT); - averagePrice = MathUtils.roundDoubleToLong(accumulatedAmountAsDouble / (double) accumulatedVolume); - } else { - isBullish = close > open; - double accumulatedVolumeAsDouble = MathUtils.scaleUpByPowerOf10((double) accumulatedVolume, Coin.SMALLEST_UNIT_EXPONENT); - averagePrice = MathUtils.roundDoubleToLong(accumulatedVolumeAsDouble / (double) accumulatedAmount); - } - - Date dateFrom = new Date(getTimeFromTickIndex(tick)); - Date dateTo = new Date(getTimeFromTickIndex(tick + 1)); - String dateString = tickUnit.ordinal() > TickUnit.DAY.ordinal() ? - DisplayUtils.formatDateTimeSpan(dateFrom, dateTo) : - DisplayUtils.formatDate(dateFrom) + " - " + DisplayUtils.formatDate(dateTo); - - // We do not need precision, so we scale down before multiplication otherwise we could get an overflow. - averageUsdPrice = (long) MathUtils.scaleDownByPowerOf10((double) averageUsdPrice, 4); - long volumeInUsd = averageUsdPrice * (long) MathUtils.scaleDownByPowerOf10((double) accumulatedAmount, 4); - // We store USD value without decimals as its only total volume, no precision is needed. - volumeInUsd = (long) MathUtils.scaleDownByPowerOf10((double) volumeInUsd, 4); - return new CandleData(tick, open, close, high, low, averagePrice, medianPrice, accumulatedAmount, accumulatedVolume, - numTrades, isBullish, dateString, volumeInUsd); - } - - Date roundToTick(Date time, TickUnit tickUnit) { - return roundToTick(time.toInstant().atZone(ZONE_ID).toLocalDateTime(), tickUnit); - } - - Date roundToTick(LocalDateTime localDate, TickUnit tickUnit) { - switch (tickUnit) { - case YEAR: - return Date.from(localDate.withMonth(1).withDayOfYear(1).withHour(0).withMinute(0).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); - case MONTH: - return Date.from(localDate.withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); - case WEEK: - int dayOfWeek = localDate.getDayOfWeek().getValue(); - LocalDateTime firstDayOfWeek = ChronoUnit.DAYS.addTo(localDate, 1 - dayOfWeek); - return Date.from(firstDayOfWeek.withHour(0).withMinute(0).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); - case DAY: - return Date.from(localDate.withHour(0).withMinute(0).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); - case HOUR: - return Date.from(localDate.withMinute(0).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); - case MINUTE_10: - return Date.from(localDate.withMinute(localDate.getMinute() - localDate.getMinute() % 10).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); - default: - return Date.from(localDate.atZone(ZONE_ID).toInstant()); - } - } - - private long getTimeFromTick(long tick) { - if (itemsPerInterval == null || itemsPerInterval.get(tick) == null) return 0; - return itemsPerInterval.get(tick).getKey().getTime(); - } - - long getTimeFromTickIndex(long index) { - if (index > maxTicks + 1) return 0; - return getTimeFromTick(index); - } - private boolean isShowAllEntry(@Nullable String id) { return id != null && id.equals(GUIUtil.SHOW_ALL_FLAG); } diff --git a/desktop/src/test/java/bisq/desktop/main/market/trades/TradesChartsViewModelTest.java b/desktop/src/test/java/bisq/desktop/main/market/trades/TradesChartsViewModelTest.java index 3fc291f2739..492121b6aca 100644 --- a/desktop/src/test/java/bisq/desktop/main/market/trades/TradesChartsViewModelTest.java +++ b/desktop/src/test/java/bisq/desktop/main/market/trades/TradesChartsViewModelTest.java @@ -35,6 +35,8 @@ import javafx.collections.FXCollections; import javafx.collections.ObservableSet; +import javafx.util.Pair; + import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; @@ -45,6 +47,7 @@ import java.util.ArrayList; import java.util.Date; import java.util.HashSet; +import java.util.Map; import java.util.Set; import org.junit.Before; @@ -115,7 +118,8 @@ public void setup() throws IOException { @SuppressWarnings("ConstantConditions") @Test public void testGetCandleData() { - model.selectedTradeCurrencyProperty.setValue(new FiatCurrency("EUR")); + String currencyCode = "EUR"; + model.selectedTradeCurrencyProperty.setValue(new FiatCurrency(currencyCode)); long low = Fiat.parseFiat("EUR", "500").value; long open = Fiat.parseFiat("EUR", "520").value; @@ -167,7 +171,13 @@ public void testGetCandleData() { null, null)); - CandleData candleData = model.getCandleData(model.roundToTick(now, TradesChartsViewModel.TickUnit.DAY).getTime(), set, 0); + Map>> itemsPerInterval = null; + long tick = ChartCalculations.roundToTick(now, TradesChartsViewModel.TickUnit.DAY).getTime(); + CandleData candleData = ChartCalculations.getCandleData(tick, + set, + 0, + TradesChartsViewModel.TickUnit.DAY, currencyCode, + itemsPerInterval); assertEquals(open, candleData.open); assertEquals(close, candleData.close); assertEquals(high, candleData.high);