diff --git a/common/src/main/java/bisq/common/util/ComparableExt.java b/common/src/main/java/bisq/common/util/ComparableExt.java new file mode 100644 index 0000000000..3e360deeaf --- /dev/null +++ b/common/src/main/java/bisq/common/util/ComparableExt.java @@ -0,0 +1,65 @@ +/* + * 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.NavigableSet; +import java.util.function.Predicate; + +import javax.annotation.Nullable; + +import static bisq.common.util.Preconditions.checkComparatorNullOrNatural; + +/** + * A {@link Comparable} which may be compared with adhoc mark/delimiter objects, in + * addition to objects of the same type, to support searches for adjacent elements in + * a sorted collection without having to use dummy objects for this purpose. For example, + * one may wish to find the smallest object after a given date, in a collection sorted by + * date. This is to work round the limitation that {@link java.util.SortedSet} and + * {@link java.util.SortedMap} only support comparison with other keys when searching for + * elements rather than allowing general binary searches with a predicate. + * + *

Implementations should define {@link Comparable#compareTo(Object)} like follows: + *

{@code
+ * public int compareTo(@NotNull ComparableExt o) {
+ *     return o instanceof Foo ? this.normalCompareTo((Foo) o) : -o.compareTo(this);
+ * }
+ * }
+ * @param + */ +public interface ComparableExt extends Comparable> { + @SuppressWarnings("unchecked") + @Nullable + static > E lower(NavigableSet set, Predicate filter) { + checkComparatorNullOrNatural(set.comparator(), "Set must be naturally ordered"); + return (E) ((NavigableSet>) set).lower(Mark.of(filter)); + } + + @SuppressWarnings("unchecked") + @Nullable + static > E higher(NavigableSet set, Predicate filter) { + checkComparatorNullOrNatural(set.comparator(), "Set must be naturally ordered"); + return (E) ((NavigableSet>) set).higher(Mark.of(filter)); + } + + interface Mark extends ComparableExt { + @SuppressWarnings("unchecked") + static Mark of(Predicate filter) { + return x -> x instanceof Mark ? 0 : filter.test((T) x) ? -1 : 1; + } + } +} diff --git a/common/src/main/java/bisq/common/util/MathUtils.java b/common/src/main/java/bisq/common/util/MathUtils.java index 77c702b65d..44212a935d 100644 --- a/common/src/main/java/bisq/common/util/MathUtils.java +++ b/common/src/main/java/bisq/common/util/MathUtils.java @@ -101,7 +101,7 @@ public class MathUtils { return BigDecimal.valueOf(value1).multiply(BigDecimal.valueOf(value2)).doubleValue(); } - public static long getMedian(Long[] list) { + public static long getMedian(long[] list) { if (list.length == 0) { return 0L; } diff --git a/common/src/main/java/bisq/common/util/Preconditions.java b/common/src/main/java/bisq/common/util/Preconditions.java index e78215d540..63af13c3e3 100644 --- a/common/src/main/java/bisq/common/util/Preconditions.java +++ b/common/src/main/java/bisq/common/util/Preconditions.java @@ -1,7 +1,12 @@ package bisq.common.util; +import com.google.common.collect.Ordering; + import java.io.File; +import java.util.Comparator; + +import static com.google.common.base.Preconditions.checkArgument; import static java.lang.String.format; /** @@ -29,4 +34,10 @@ public class Preconditions { return dir; } + + // needed since Guava makes it impossible to create an ImmutableSorted[Set|Map] with a null comparator: + public static void checkComparatorNullOrNatural(Comparator comparator, Object errorMessage) { + checkArgument(comparator == null || comparator.equals(Ordering.natural()) || + comparator.equals(Comparator.naturalOrder()), errorMessage); + } } diff --git a/common/src/main/java/bisq/common/util/RangeUtils.java b/common/src/main/java/bisq/common/util/RangeUtils.java new file mode 100644 index 0000000000..80e87ae511 --- /dev/null +++ b/common/src/main/java/bisq/common/util/RangeUtils.java @@ -0,0 +1,82 @@ +/* + * 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 com.google.common.collect.BoundType; +import com.google.common.collect.Range; + +import java.util.Collections; +import java.util.NavigableSet; +import java.util.function.Function; +import java.util.function.Predicate; + +public final class RangeUtils { + private RangeUtils() { + } + + public static > NavigableSet subSet(NavigableSet set, + Predicate fromFilter, + Predicate toFilter) { + E fromElement = ComparableExt.higher(set, fromFilter); + E toElement = ComparableExt.lower(set, toFilter); + return fromElement != null && toElement != null && fromElement.compareTo(toElement) <= 0 ? + set.subSet(fromElement, true, toElement, true) : Collections.emptyNavigableSet(); + } + + public static > SubCollection, E> subSet(NavigableSet set) { + return new SubCollection<>() { + @Override + public > WithKeyFunction, K> withKey(Function increasingKeyFn) { + return (Range range) -> { + var fromToFilter = boundFilters(increasingKeyFn, range); + return subSet(set, fromToFilter.first, fromToFilter.second); + }; + } + }; + } + + private static > Tuple2, Predicate> boundFilters(Function keyFn, + Range keyRange) { + Predicate fromFilter, toFilter; + if (keyRange.hasLowerBound()) { + K fromKey = keyRange.lowerEndpoint(); + fromFilter = keyRange.lowerBoundType() == BoundType.CLOSED + ? (E e) -> fromKey.compareTo(keyFn.apply(e)) <= 0 + : (E e) -> fromKey.compareTo(keyFn.apply(e)) < 0; + } else { + fromFilter = e -> true; + } + if (keyRange.hasUpperBound()) { + K toKey = keyRange.upperEndpoint(); + toFilter = keyRange.upperBoundType() == BoundType.CLOSED + ? (E e) -> toKey.compareTo(keyFn.apply(e)) < 0 + : (E e) -> toKey.compareTo(keyFn.apply(e)) <= 0; + } else { + toFilter = e -> false; + } + return new Tuple2<>(fromFilter, toFilter); + } + + public interface SubCollection { + > WithKeyFunction withKey(Function increasingKeyFn); + } + + public interface WithKeyFunction> { + C overRange(Range range); + } +} diff --git a/common/src/test/java/bisq/common/util/RangeUtilsTest.java b/common/src/test/java/bisq/common/util/RangeUtilsTest.java new file mode 100644 index 0000000000..ce0afa090e --- /dev/null +++ b/common/src/test/java/bisq/common/util/RangeUtilsTest.java @@ -0,0 +1,103 @@ +/* + * 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 com.google.common.collect.ImmutableSortedSet; + +import java.util.Comparator; +import java.util.NavigableSet; +import java.util.stream.IntStream; + +import org.jetbrains.annotations.NotNull; + +import org.junit.jupiter.api.Test; + +import static bisq.common.util.RangeUtils.subSet; +import static com.google.common.collect.Range.*; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class RangeUtilsTest { + @Test + public void subSetWithStrictlyIncreasingKey() { + var subSetWithValue = subSet(range(0, 10)).withKey(n -> n.value); + + assertEquals(range(0, 10), subSetWithValue.overRange(all())); + assertEquals(range(0, 6), subSetWithValue.overRange(atMost(5))); + assertEquals(range(5, 10), subSetWithValue.overRange(atLeast(5))); + assertEquals(range(0, 5), subSetWithValue.overRange(lessThan(5))); + assertEquals(range(6, 10), subSetWithValue.overRange(greaterThan(5))); + assertEquals(range(3, 8), subSetWithValue.overRange(closed(3, 7))); + assertEquals(range(3, 7), subSetWithValue.overRange(closedOpen(3, 7))); + assertEquals(range(4, 8), subSetWithValue.overRange(openClosed(3, 7))); + assertEquals(range(4, 7), subSetWithValue.overRange(open(3, 7))); + assertEquals(range(5, 6), subSetWithValue.overRange(singleton(5))); + assertEquals(range(0, 1), subSetWithValue.overRange(singleton(0))); + assertEquals(range(0, 0), subSetWithValue.overRange(singleton(-1))); + assertEquals(range(9, 10), subSetWithValue.overRange(singleton(9))); + assertEquals(range(0, 0), subSetWithValue.overRange(singleton(10))); + assertEquals(range(0, 0), subSetWithValue.overRange(closedOpen(5, 5))); + assertEquals(range(0, 10), subSetWithValue.overRange(closed(-1, 10))); + } + + @Test + public void subSetWithNonStrictlyIncreasingKey() { + var subSetWithValueDiv3 = subSet(range(0, 10)).withKey(n -> n.value / 3); + + assertEquals(range(0, 10), subSetWithValueDiv3.overRange(closed(0, 3))); + assertEquals(range(0, 9), subSetWithValueDiv3.overRange(closedOpen(0, 3))); + assertEquals(range(3, 10), subSetWithValueDiv3.overRange(openClosed(0, 3))); + assertEquals(range(3, 9), subSetWithValueDiv3.overRange(open(0, 3))); + assertEquals(range(0, 3), subSetWithValueDiv3.overRange(singleton(0))); + assertEquals(range(3, 6), subSetWithValueDiv3.overRange(singleton(1))); + assertEquals(range(9, 10), subSetWithValueDiv3.overRange(singleton(3))); + } + + private static NavigableSet range(int startInclusive, int endExclusive) { + return IntStream.range(startInclusive, endExclusive) + .mapToObj(TestInteger::new) + .collect(ImmutableSortedSet.toImmutableSortedSet(Comparator.naturalOrder())); + } + + private static final class TestInteger implements ComparableExt { + int value; + + TestInteger(int value) { + this.value = value; + } + + @Override + public int compareTo(@NotNull ComparableExt o) { + return o instanceof TestInteger ? Integer.compare(value, ((TestInteger) o).value) : -o.compareTo(this); + } + + @Override + public boolean equals(Object o) { + return this == o || o instanceof TestInteger && value == ((TestInteger) o).value; + } + + @Override + public int hashCode() { + return Integer.hashCode(value); + } + + @Override + public String toString() { + return Integer.toString(value); + } + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/BurningManPresentationService.java b/core/src/main/java/bisq/core/dao/burningman/BurningManPresentationService.java index 7bfbe8fdb5..d32843536c 100644 --- a/core/src/main/java/bisq/core/dao/burningman/BurningManPresentationService.java +++ b/core/src/main/java/bisq/core/dao/burningman/BurningManPresentationService.java @@ -90,6 +90,7 @@ public class BurningManPresentationService implements DaoStateListener { private int currentChainHeight; private Optional burnTarget = Optional.empty(); private final Map burningManCandidatesByName = new HashMap<>(); + private Long accumulatedDecayedBurnedAmount; private final Set reimbursements = new HashSet<>(); private Optional averageDistributionPerCycle = Optional.empty(); private Set myCompensationRequestNames = null; @@ -132,6 +133,7 @@ public class BurningManPresentationService implements DaoStateListener { burningManCandidatesByName.clear(); reimbursements.clear(); burnTarget = Optional.empty(); + accumulatedDecayedBurnedAmount = null; myCompensationRequestNames = null; averageDistributionPerCycle = Optional.empty(); legacyBurningManDPT = Optional.empty(); @@ -188,8 +190,7 @@ public class BurningManPresentationService implements DaoStateListener { long lowerBaseTarget = Math.round(burnTarget * maxCompensationShare); double maxBoostedCompensationShare = burningManCandidate.getMaxBoostedCompensationShare(); long upperBaseTarget = Math.round(boostedBurnTarget * maxBoostedCompensationShare); - Collection burningManCandidates = getBurningManCandidatesByName().values(); - long totalBurnedAmount = burnTargetService.getAccumulatedDecayedBurnedAmount(burningManCandidates, currentChainHeight); + long totalBurnedAmount = getAccumulatedDecayedBurnedAmount(); if (totalBurnedAmount == 0) { // The first BM would reach their max burn share by 5.46 BSQ already. But we suggest the lowerBaseTarget @@ -344,7 +345,6 @@ public class BurningManPresentationService implements DaoStateListener { receiverAddressesByBurningManName.get(name).addAll(burningManCandidate.getAllAddresses()); }); - Map map = new HashMap<>(); receiverAddressesByBurningManName .forEach((name, addresses) -> addresses @@ -371,4 +371,12 @@ public class BurningManPresentationService implements DaoStateListener { proofOfBurnOpReturnTxOutputByHash.putAll(burningManService.getProofOfBurnOpReturnTxOutputByHash(currentChainHeight)); return proofOfBurnOpReturnTxOutputByHash; } + + private long getAccumulatedDecayedBurnedAmount() { + if (accumulatedDecayedBurnedAmount == null) { + Collection burningManCandidates = getBurningManCandidatesByName().values(); + accumulatedDecayedBurnedAmount = burnTargetService.getAccumulatedDecayedBurnedAmount(burningManCandidates, currentChainHeight); + } + return accumulatedDecayedBurnedAmount; + } } diff --git a/core/src/main/java/bisq/core/dao/burningman/accounting/BurningManAccountingService.java b/core/src/main/java/bisq/core/dao/burningman/accounting/BurningManAccountingService.java index 6898843e35..3605eb981d 100644 --- a/core/src/main/java/bisq/core/dao/burningman/accounting/BurningManAccountingService.java +++ b/core/src/main/java/bisq/core/dao/burningman/accounting/BurningManAccountingService.java @@ -32,6 +32,7 @@ import bisq.core.dao.state.DaoStateListener; import bisq.core.dao.state.DaoStateService; import bisq.core.dao.state.model.blockchain.Block; import bisq.core.monetary.Price; +import bisq.core.trade.statistics.TradeStatistics3; import bisq.core.trade.statistics.TradeStatisticsManager; import bisq.core.user.Preferences; import bisq.core.util.AveragePriceUtil; @@ -49,6 +50,8 @@ import javax.inject.Singleton; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; +import javafx.collections.SetChangeListener; + import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; @@ -58,6 +61,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.OptionalLong; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -87,6 +91,7 @@ public class BurningManAccountingService implements DaoSetupService, DaoStateLis private final Preferences preferences; private final Map averageBsqPriceByMonth = new HashMap<>(getHistoricalAverageBsqPriceByMonth()); + private boolean averagePricesValid; @Getter private final Map balanceModelByBurningManName = new HashMap<>(); @Getter @@ -116,13 +121,13 @@ public class BurningManAccountingService implements DaoSetupService, DaoStateLis @Override public void addListeners() { + tradeStatisticsManager.getObservableTradeStatisticsSet().addListener( + (SetChangeListener) observable -> averagePricesValid = false); } @Override public void start() { UserThread.execute(() -> isProcessing.set(true)); - // Create the map from now back to the last entry of the historical data (April 2019-Nov. 2022). - averageBsqPriceByMonth.putAll(getAverageBsqPriceByMonth(new Date(), 2022, 10)); updateBalanceModelByAddress(); CompletableFuture.runAsync(() -> { @@ -180,8 +185,11 @@ public class BurningManAccountingService implements DaoSetupService, DaoStateLis } public Map getAverageBsqPriceByMonth() { - getAverageBsqPriceByMonth(new Date(), HIST_BSQ_PRICE_LAST_DATE_YEAR, HIST_BSQ_PRICE_LAST_DATE_MONTH) - .forEach((key, value) -> averageBsqPriceByMonth.put(new Date(key.getTime()), Price.valueOf("BSQ", value.getValue()))); + if (!averagePricesValid) { + // Fill the map from now back to the last entry of the historical data (April 2019-Nov. 2022). + averageBsqPriceByMonth.putAll(getAverageBsqPriceByMonth(new Date(), HIST_BSQ_PRICE_LAST_DATE_YEAR, HIST_BSQ_PRICE_LAST_DATE_MONTH)); + averagePricesValid = true; + } return averageBsqPriceByMonth; } @@ -202,21 +210,7 @@ public class BurningManAccountingService implements DaoSetupService, DaoStateLis Map averageBsqPriceByMonth = getAverageBsqPriceByMonth(); return getReceivedBtcBalanceEntryListExcludingLegacyBM().stream() .filter(e -> e.getType() == BalanceEntry.Type.BTC_TRADE_FEE_TX) - .map(balanceEntry -> { - Date month = balanceEntry.getMonth(); - Optional price = Optional.ofNullable(averageBsqPriceByMonth.get(month)); - long receivedBtc = balanceEntry.getAmount(); - Optional receivedBtcAsBsq; - if (price.isEmpty() || price.get().getValue() == 0) { - receivedBtcAsBsq = Optional.empty(); - } else { - long volume = price.get().getVolumeByAmount(Coin.valueOf(receivedBtc)).getValue(); - receivedBtcAsBsq = Optional.of(MathUtils.roundDoubleToLong(MathUtils.scaleDownByPowerOf10(volume, 6))); - } - return receivedBtcAsBsq; - }) - .filter(Optional::isPresent) - .mapToLong(Optional::get) + .flatMapToLong(balanceEntry -> receivedBtcAsBsq(balanceEntry, averageBsqPriceByMonth).stream()) .sum(); } @@ -231,45 +225,29 @@ public class BurningManAccountingService implements DaoSetupService, DaoStateLis Map averageBsqPriceByMonth = getAverageBsqPriceByMonth(); return getReceivedBtcBalanceEntryListExcludingLegacyBM().stream() .filter(e -> e.getType() == BalanceEntry.Type.DPT_TX) - .map(balanceEntry -> { - Date month = balanceEntry.getMonth(); - Optional price = Optional.ofNullable(averageBsqPriceByMonth.get(month)); - long receivedBtc = balanceEntry.getAmount(); - Optional receivedBtcAsBsq; - if (price.isEmpty() || price.get().getValue() == 0) { - receivedBtcAsBsq = Optional.empty(); - } else { - long volume = price.get().getVolumeByAmount(Coin.valueOf(receivedBtc)).getValue(); - receivedBtcAsBsq = Optional.of(MathUtils.roundDoubleToLong(MathUtils.scaleDownByPowerOf10(volume, 6))); - } - return receivedBtcAsBsq; - }) - .filter(Optional::isPresent) - .mapToLong(Optional::get) + .flatMapToLong(balanceEntry -> receivedBtcAsBsq(balanceEntry, averageBsqPriceByMonth).stream()) .sum(); } public long getTotalAmountOfDistributedBsq() { Map averageBsqPriceByMonth = getAverageBsqPriceByMonth(); return getReceivedBtcBalanceEntryListExcludingLegacyBM().stream() - .map(balanceEntry -> { - Date month = balanceEntry.getMonth(); - Optional price = Optional.ofNullable(averageBsqPriceByMonth.get(month)); - long receivedBtc = balanceEntry.getAmount(); - Optional receivedBtcAsBsq; - if (price.isEmpty() || price.get().getValue() == 0) { - receivedBtcAsBsq = Optional.empty(); - } else { - long volume = price.get().getVolumeByAmount(Coin.valueOf(receivedBtc)).getValue(); - receivedBtcAsBsq = Optional.of(MathUtils.roundDoubleToLong(MathUtils.scaleDownByPowerOf10(volume, 6))); - } - return receivedBtcAsBsq; - }) - .filter(Optional::isPresent) - .mapToLong(Optional::get) + .flatMapToLong(balanceEntry -> receivedBtcAsBsq(balanceEntry, averageBsqPriceByMonth).stream()) .sum(); } + private static OptionalLong receivedBtcAsBsq(ReceivedBtcBalanceEntry balanceEntry, + Map averageBsqPriceByMonth) { + Date month = balanceEntry.getMonth(); + long receivedBtc = balanceEntry.getAmount(); + Price price = averageBsqPriceByMonth.get(month); + if (price == null) { + return OptionalLong.empty(); + } + long volume = price.getVolumeByAmount(Coin.valueOf(receivedBtc)).getValue(); + return OptionalLong.of(MathUtils.roundDoubleToLong(MathUtils.scaleDownByPowerOf10(volume, 6))); + } + private List getReceivedBtcBalanceEntryListExcludingLegacyBM() { if (!receivedBtcBalanceEntryListExcludingLegacyBM.isEmpty()) { return receivedBtcBalanceEntryListExcludingLegacyBM; @@ -329,18 +307,18 @@ public class BurningManAccountingService implements DaoSetupService, DaoStateLis private void addAccountingBlockToBalanceModel(Map balanceModelByBurningManName, AccountingBlock accountingBlock) { - accountingBlock.getTxs().forEach(tx -> { - tx.getOutputs().forEach(txOutput -> { - String name = txOutput.getName(); - balanceModelByBurningManName.putIfAbsent(name, new BalanceModel()); - balanceModelByBurningManName.get(name).addReceivedBtcBalanceEntry(new ReceivedBtcBalanceEntry(tx.getTruncatedTxId(), - txOutput.getValue(), - new Date(accountingBlock.getDate()), - toBalanceEntryType(tx.getType()))); - }); - }); + accountingBlock.getTxs().forEach(tx -> + tx.getOutputs().forEach(txOutput -> { + String name = txOutput.getName(); + balanceModelByBurningManName.putIfAbsent(name, new BalanceModel()); + balanceModelByBurningManName.get(name).addReceivedBtcBalanceEntry(new ReceivedBtcBalanceEntry(tx.getTruncatedTxId(), + txOutput.getValue(), + new Date(accountingBlock.getDate()), + toBalanceEntryType(tx.getType()))); + })); } + @SuppressWarnings("SameParameterValue") private Map getAverageBsqPriceByMonth(Date from, int backToYear, int backToMonth) { Map averageBsqPriceByMonth = new HashMap<>(); Calendar calendar = new GregorianCalendar(); diff --git a/core/src/main/java/bisq/core/monetary/AltcoinExchangeRate.java b/core/src/main/java/bisq/core/monetary/AltcoinExchangeRate.java index 4064c2c288..4e3f53402e 100644 --- a/core/src/main/java/bisq/core/monetary/AltcoinExchangeRate.java +++ b/core/src/main/java/bisq/core/monetary/AltcoinExchangeRate.java @@ -60,34 +60,39 @@ public class AltcoinExchangeRate { * @throws ArithmeticException if the converted altcoin amount is too high or too low. */ public Altcoin coinToAltcoin(Coin convertCoin) { - BigInteger converted = BigInteger.valueOf(coin.value) - .multiply(BigInteger.valueOf(convertCoin.value)) - .divide(BigInteger.valueOf(altcoin.value)); - if (converted.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0 - || converted.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0) - throw new ArithmeticException("Overflow"); - return Altcoin.valueOf(altcoin.currencyCode, converted.longValue()); + long converted; + if ((int) coin.value == coin.value && (int) convertCoin.value == convertCoin.value) { + // Short circuit in a common case where long arithmetic won't overflow. + converted = coin.value * convertCoin.value / altcoin.value; + } else { + // Otherwise use BigInteger to maintain full precision without overflowing. + converted = BigInteger.valueOf(coin.value) + .multiply(BigInteger.valueOf(convertCoin.value)) + .divide(BigInteger.valueOf(altcoin.value)) + .longValueExact(); + } + return Altcoin.valueOf(altcoin.currencyCode, converted); } /** - * Convert a altcoin amount to a coin amount using this exchange rate. + * Convert an altcoin amount to a coin amount using this exchange rate. * * @throws ArithmeticException if the converted coin amount is too high or too low. */ public Coin altcoinToCoin(Altcoin convertAltcoin) { checkArgument(convertAltcoin.currencyCode.equals(altcoin.currencyCode), "Currency mismatch: %s vs %s", convertAltcoin.currencyCode, altcoin.currencyCode); - // Use BigInteger because it's much easier to maintain full precision without overflowing. - BigInteger converted = BigInteger.valueOf(altcoin.value) - .multiply(BigInteger.valueOf(convertAltcoin.value)) - .divide(BigInteger.valueOf(coin.value)); - if (converted.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0 - || converted.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0) - throw new ArithmeticException("Overflow"); - try { - return Coin.valueOf(converted.longValue()); - } catch (IllegalArgumentException x) { - throw new ArithmeticException("Overflow: " + x.getMessage()); + long converted; + if ((int) altcoin.value == altcoin.value && (int) convertAltcoin.value == convertAltcoin.value) { + // Short circuit in a common case where long arithmetic won't overflow. + converted = altcoin.value * convertAltcoin.value / coin.value; + } else { + // Otherwise use BigInteger to maintain full precision without overflowing. + converted = BigInteger.valueOf(altcoin.value) + .multiply(BigInteger.valueOf(convertAltcoin.value)) + .divide(BigInteger.valueOf(coin.value)) + .longValueExact(); } + return Coin.valueOf(converted); } } diff --git a/core/src/main/java/bisq/core/monetary/Price.java b/core/src/main/java/bisq/core/monetary/Price.java index e07a896efe..6e0677fe30 100644 --- a/core/src/main/java/bisq/core/monetary/Price.java +++ b/core/src/main/java/bisq/core/monetary/Price.java @@ -25,9 +25,6 @@ import org.bitcoinj.core.Monetary; import org.bitcoinj.utils.ExchangeRate; import org.bitcoinj.utils.Fiat; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import org.jetbrains.annotations.NotNull; /** @@ -39,12 +36,11 @@ import org.jetbrains.annotations.NotNull; * those classes, like {@link Fiat} or {@link Altcoin}. */ public class Price extends MonetaryWrapper implements Comparable { - private static final Logger log = LoggerFactory.getLogger(Price.class); /** * Create a new {@code Price} from specified {@code Monetary}. * - * @param monetary + * @param monetary The monetary (either {@link Altcoin} or {@link Fiat}) to wrap */ public Price(Monetary monetary) { super(monetary); @@ -82,23 +78,41 @@ public class Price extends MonetaryWrapper implements Comparable { public Volume getVolumeByAmount(Coin amount) { if (monetary instanceof Fiat) - return new Volume(new ExchangeRate((Fiat) monetary).coinToFiat(amount)); + return new Volume(coinToFiat(new ExchangeRate((Fiat) monetary), amount)); else if (monetary instanceof Altcoin) return new Volume(new AltcoinExchangeRate((Altcoin) monetary).coinToAltcoin(amount)); else throw new IllegalStateException("Monetary must be either of type Fiat or Altcoin"); } + // Short circuit BigInteger logic in ExchangeRate.coinToFiat in a common case where long arithmetic won't overflow. + private static Fiat coinToFiat(ExchangeRate rate, Coin convertCoin) { + if ((int) convertCoin.value == convertCoin.value && (int) rate.fiat.value == rate.fiat.value) { + long converted = convertCoin.value * rate.fiat.value / rate.coin.value; + return Fiat.valueOf(rate.fiat.currencyCode, converted); + } + return rate.coinToFiat(convertCoin); + } + public Coin getAmountByVolume(Volume volume) { Monetary monetary = volume.getMonetary(); if (monetary instanceof Fiat && this.monetary instanceof Fiat) - return new ExchangeRate((Fiat) this.monetary).fiatToCoin((Fiat) monetary); + return fiatToCoin(new ExchangeRate((Fiat) this.monetary), (Fiat) monetary); else if (monetary instanceof Altcoin && this.monetary instanceof Altcoin) return new AltcoinExchangeRate((Altcoin) this.monetary).altcoinToCoin((Altcoin) monetary); else return Coin.ZERO; } + // Short circuit BigInteger logic in ExchangeRate.fiatToCoin in a common case where long arithmetic won't overflow. + private static Coin fiatToCoin(ExchangeRate rate, Fiat convertFiat) { + if ((int) convertFiat.value == convertFiat.value && (int) rate.coin.value == rate.coin.value) { + long converted = convertFiat.value * rate.coin.value / rate.fiat.value; + return Coin.valueOf(converted); + } + return rate.fiatToCoin(convertFiat); + } + public String getCurrencyCode() { return monetary instanceof Altcoin ? ((Altcoin) monetary).getCurrencyCode() : ((Fiat) monetary).getCurrencyCode(); } diff --git a/core/src/main/java/bisq/core/offer/availability/DisputeAgentSelection.java b/core/src/main/java/bisq/core/offer/availability/DisputeAgentSelection.java index eaec9dcbe9..fe15585858 100644 --- a/core/src/main/java/bisq/core/offer/availability/DisputeAgentSelection.java +++ b/core/src/main/java/bisq/core/offer/availability/DisputeAgentSelection.java @@ -26,8 +26,6 @@ import bisq.common.util.Tuple2; import com.google.common.annotations.VisibleForTesting; -import java.util.ArrayList; -import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Objects; @@ -35,6 +33,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; +import java.util.stream.Stream; import lombok.extern.slf4j.Slf4j; @@ -62,16 +61,11 @@ public class DisputeAgentSelection { DisputeAgentManager disputeAgentManager, boolean isMediator) { // We take last 100 entries from trade statistics - List list = new ArrayList<>(tradeStatisticsManager.getObservableTradeStatisticsSet()); - list.sort(Comparator.comparing(TradeStatistics3::getDateAsLong)); - Collections.reverse(list); - if (!list.isEmpty()) { - int max = Math.min(list.size(), LOOK_BACK_RANGE); - list = list.subList(0, max); - } + Stream stream = tradeStatisticsManager.getNavigableTradeStatisticsSet().descendingSet().stream() + .limit(LOOK_BACK_RANGE); // We stored only first 4 chars of disputeAgents onion address - List lastAddressesUsedInTrades = list.stream() + List lastAddressesUsedInTrades = stream .map(tradeStatistics3 -> isMediator ? tradeStatistics3.getMediator() : tradeStatistics3.getRefundAgent()) .filter(Objects::nonNull) .collect(Collectors.toList()); diff --git a/core/src/main/java/bisq/core/payment/TradeLimits.java b/core/src/main/java/bisq/core/payment/TradeLimits.java index 2d3c5c5688..6bd38f38de 100644 --- a/core/src/main/java/bisq/core/payment/TradeLimits.java +++ b/core/src/main/java/bisq/core/payment/TradeLimits.java @@ -19,9 +19,9 @@ package bisq.core.payment; import bisq.core.dao.governance.param.Param; import bisq.core.dao.governance.period.PeriodService; +import bisq.core.dao.state.DaoStateListener; import bisq.core.dao.state.DaoStateService; - -import bisq.common.util.MathUtils; +import bisq.core.dao.state.model.blockchain.Block; import org.bitcoinj.core.Coin; @@ -37,7 +37,7 @@ import javax.annotation.Nullable; @Slf4j @Singleton -public class TradeLimits { +public class TradeLimits implements DaoStateListener { @Nullable @Getter private static TradeLimits INSTANCE; @@ -45,10 +45,14 @@ public class TradeLimits { private final DaoStateService daoStateService; private final PeriodService periodService; + private volatile Coin cachedMaxTradeLimit; + @Inject public TradeLimits(DaoStateService daoStateService, PeriodService periodService) { this.daoStateService = daoStateService; this.periodService = periodService; + + daoStateService.addDaoStateListener(this); INSTANCE = this; } @@ -58,6 +62,10 @@ public class TradeLimits { // guice. } + @Override + public void onParseBlockCompleteAfterBatchProcessing(Block block) { + cachedMaxTradeLimit = null; + } /** * The default trade limits defined as statics in PaymentMethod are only used until the DAO @@ -67,7 +75,11 @@ public class TradeLimits { * @return the maximum trade limit set by the DAO. */ public Coin getMaxTradeLimit() { - return daoStateService.getParamValueAsCoin(Param.MAX_TRADE_LIMIT, periodService.getChainHeight()); + Coin limit = cachedMaxTradeLimit; + if (limit == null) { + cachedMaxTradeLimit = limit = daoStateService.getParamValueAsCoin(Param.MAX_TRADE_LIMIT, periodService.getChainHeight()); + } + return limit; } // We possibly rounded value for the first month gets multiplied by 4 to get the trade limit after the account @@ -98,8 +110,6 @@ public class TradeLimits { long smallestLimit = maxLimit / (4 * riskFactor); // e.g. 100000000 / 32 = 3125000 // We want to avoid more than 4 decimal places (100000000 / 32 = 3125000 or 1 BTC / 32 = 0.03125 BTC). // We want rounding to 0.0313 BTC - double decimalForm = MathUtils.scaleDownByPowerOf10((double) smallestLimit, 8); - double rounded = MathUtils.roundDouble(decimalForm, 4); - return MathUtils.roundDoubleToLong(MathUtils.scaleUpByPowerOf10(rounded, 8)); + return ((smallestLimit + 5000L) / 10000L) * 10000L; } } diff --git a/core/src/main/java/bisq/core/payment/payload/PaymentMethod.java b/core/src/main/java/bisq/core/payment/payload/PaymentMethod.java index 32d212d344..877c19849f 100644 --- a/core/src/main/java/bisq/core/payment/payload/PaymentMethod.java +++ b/core/src/main/java/bisq/core/payment/payload/PaymentMethod.java @@ -26,11 +26,13 @@ import bisq.common.proto.persistable.PersistablePayload; import org.bitcoinj.core.Coin; -import java.util.ArrayList; -import java.util.Arrays; +import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -196,8 +198,7 @@ public final class PaymentMethod implements PersistablePayload, Comparable paymentMethods = new ArrayList<>(Arrays.asList( + private static final List PAYMENT_METHODS = Stream.of( // EUR SEPA = new PaymentMethod(SEPA_ID, 6 * DAY, DEFAULT_TRADE_LIMIT_HIGH_RISK), SEPA_INSTANT = new PaymentMethod(SEPA_INSTANT_ID, DAY, DEFAULT_TRADE_LIMIT_HIGH_RISK), @@ -277,18 +278,15 @@ public final class PaymentMethod implements PersistablePayload, Comparable m.id.equals(CLEAR_X_CHANGE_ID) ? "ZELLE" : m.id) + ).collect(Collectors.toUnmodifiableList()); - static { - paymentMethods.sort((o1, o2) -> { - String id1 = o1.getId(); - if (id1.equals(CLEAR_X_CHANGE_ID)) - id1 = "ZELLE"; - String id2 = o2.getId(); - if (id2.equals(CLEAR_X_CHANGE_ID)) - id2 = "ZELLE"; - return id1.compareTo(id2); - }); + private static final Map PAYMENT_METHOD_MAP = PAYMENT_METHODS.stream() + .collect(Collectors.toUnmodifiableMap(m -> m.id, m -> m)); + + public static List getPaymentMethods() { + return PAYMENT_METHODS; } public static PaymentMethod getDummyPaymentMethod(String id) { @@ -371,9 +369,7 @@ public final class PaymentMethod implements PersistablePayload, Comparable getActivePaymentMethod(String id) { - return paymentMethods.stream() - .filter(e -> e.getId().equals(id)) - .findFirst(); + return Optional.ofNullable(PAYMENT_METHOD_MAP.get(id)); } public Coin getMaxTradeLimitAsCoin(String currencyCode) { diff --git a/core/src/main/java/bisq/core/trade/statistics/TradeStatistics2.java b/core/src/main/java/bisq/core/trade/statistics/TradeStatistics2.java index 5640bb8cd4..ddcb155b5c 100644 --- a/core/src/main/java/bisq/core/trade/statistics/TradeStatistics2.java +++ b/core/src/main/java/bisq/core/trade/statistics/TradeStatistics2.java @@ -18,7 +18,6 @@ package bisq.core.trade.statistics; import bisq.core.monetary.Altcoin; -import bisq.core.monetary.AltcoinExchangeRate; import bisq.core.monetary.Price; import bisq.core.monetary.Volume; import bisq.core.offer.Offer; @@ -45,8 +44,6 @@ import bisq.common.util.Utilities; import com.google.protobuf.ByteString; import org.bitcoinj.core.Coin; -import org.bitcoinj.utils.ExchangeRate; -import org.bitcoinj.utils.Fiat; import com.google.common.base.Charsets; @@ -311,12 +308,10 @@ public final class TradeStatistics2 implements ProcessOncePersistableNetworkPayl } public Volume getTradeVolume() { - if (getTradePrice().getMonetary() instanceof Altcoin) { - return new Volume(new AltcoinExchangeRate((Altcoin) getTradePrice().getMonetary()).coinToAltcoin(getTradeAmount())); - } else { - Volume volume = new Volume(new ExchangeRate((Fiat) getTradePrice().getMonetary()).coinToFiat(getTradeAmount())); - return VolumeUtil.getRoundedFiatVolume(volume); - } + Price price = getTradePrice(); + return price.getMonetary() instanceof Altcoin + ? price.getVolumeByAmount(getTradeAmount()) + : VolumeUtil.getRoundedFiatVolume(price.getVolumeByAmount(getTradeAmount())); } public boolean isValid() { diff --git a/core/src/main/java/bisq/core/trade/statistics/TradeStatistics3.java b/core/src/main/java/bisq/core/trade/statistics/TradeStatistics3.java index 8f88f0c525..9786d1cf50 100644 --- a/core/src/main/java/bisq/core/trade/statistics/TradeStatistics3.java +++ b/core/src/main/java/bisq/core/trade/statistics/TradeStatistics3.java @@ -19,7 +19,6 @@ package bisq.core.trade.statistics; import bisq.core.locale.CurrencyUtil; import bisq.core.monetary.Altcoin; -import bisq.core.monetary.AltcoinExchangeRate; import bisq.core.monetary.Price; import bisq.core.monetary.Volume; import bisq.core.offer.Offer; @@ -41,6 +40,7 @@ import bisq.common.app.Capability; import bisq.common.crypto.Hash; import bisq.common.proto.ProtoUtil; import bisq.common.util.CollectionUtils; +import bisq.common.util.ComparableExt; import bisq.common.util.ExtraDataMapValidator; import bisq.common.util.JsonExclude; import bisq.common.util.Utilities; @@ -48,8 +48,6 @@ import bisq.common.util.Utilities; import com.google.protobuf.ByteString; import org.bitcoinj.core.Coin; -import org.bitcoinj.utils.ExchangeRate; -import org.bitcoinj.utils.Fiat; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Charsets; @@ -59,15 +57,19 @@ import java.time.ZoneId; import java.util.Arrays; import java.util.Calendar; +import java.util.Comparator; import java.util.Date; import java.util.GregorianCalendar; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.Optional; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; + import javax.annotation.Nullable; import static com.google.common.base.Preconditions.checkNotNull; @@ -78,7 +80,7 @@ import static com.google.common.base.Preconditions.checkNotNull; */ @Slf4j public final class TradeStatistics3 implements ProcessOncePersistableNetworkPayload, PersistableNetworkPayload, - CapabilityRequiringPayload, DateSortedTruncatablePayload { + CapabilityRequiringPayload, DateSortedTruncatablePayload, ComparableExt { @JsonExclude private transient static final ZoneId ZONE_ID = ZoneId.systemDefault(); @@ -138,7 +140,8 @@ public final class TradeStatistics3 implements ProcessOncePersistableNetworkPayl // The payment method string can be quite long and would consume 15% more space. // When we get a new payment method we can add it to the enum at the end. Old users would add it as string if not // recognized. - private enum PaymentMethodMapper { + @VisibleForTesting + enum PaymentMethodMapper { OK_PAY, CASH_APP, VENMO, @@ -194,7 +197,9 @@ public final class TradeStatistics3 implements ProcessOncePersistableNetworkPayl TIKKIE, TRANSFERWISE_USD, ACH_TRANSFER, - DOMESTIC_WIRE_TRANSFER + DOMESTIC_WIRE_TRANSFER; + + private static final PaymentMethodMapper[] values = values(); // cache for perf gain } @Getter @@ -298,7 +303,7 @@ public final class TradeStatistics3 implements ProcessOncePersistableNetworkPayl String tempPaymentMethod; try { tempPaymentMethod = String.valueOf(PaymentMethodMapper.valueOf(paymentMethod).ordinal()); - } catch (Throwable t) { + } catch (IllegalArgumentException e) { tempPaymentMethod = paymentMethod; } this.paymentMethod = tempPaymentMethod; @@ -382,8 +387,9 @@ public final class TradeStatistics3 implements ProcessOncePersistableNetworkPayl } public LocalDateTime getLocalDateTime() { + LocalDateTime localDateTime = this.localDateTime; if (localDateTime == null) { - localDateTime = dateObj.toInstant().atZone(ZONE_ID).toLocalDateTime(); + this.localDateTime = localDateTime = dateObj.toInstant().atZone(ZONE_ID).toLocalDateTime(); } return localDateTime; } @@ -403,9 +409,12 @@ public final class TradeStatistics3 implements ProcessOncePersistableNetworkPayl } public String getPaymentMethodId() { + if (paymentMethod.isEmpty() || paymentMethod.charAt(0) > '9') { + return paymentMethod; + } try { - return PaymentMethodMapper.values()[Integer.parseInt(paymentMethod)].name(); - } catch (Throwable ignore) { + return PaymentMethodMapper.values[Integer.parseInt(paymentMethod)].name(); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { return paymentMethod; } } @@ -413,8 +422,9 @@ public final class TradeStatistics3 implements ProcessOncePersistableNetworkPayl private transient Price priceObj; public Price getTradePrice() { + Price priceObj = this.priceObj; if (priceObj == null) { - priceObj = Price.valueOf(currency, price); + this.priceObj = priceObj = Price.valueOf(currency, price); } return priceObj; } @@ -424,13 +434,12 @@ public final class TradeStatistics3 implements ProcessOncePersistableNetworkPayl } public Volume getTradeVolume() { + Volume volume = this.volume; if (volume == null) { - if (getTradePrice().getMonetary() instanceof Altcoin) { - volume = new Volume(new AltcoinExchangeRate((Altcoin) getTradePrice().getMonetary()).coinToAltcoin(getTradeAmount())); - } else { - Volume exactVolume = new Volume(new ExchangeRate((Fiat) getTradePrice().getMonetary()).coinToFiat(getTradeAmount())); - volume = VolumeUtil.getRoundedFiatVolume(exactVolume); - } + Price price = getTradePrice(); + this.volume = volume = price.getMonetary() instanceof Altcoin + ? price.getVolumeByAmount(getTradeAmount()) + : VolumeUtil.getRoundedFiatVolume(price.getVolumeByAmount(getTradeAmount())); } return volume; } @@ -472,6 +481,27 @@ public final class TradeStatistics3 implements ProcessOncePersistableNetworkPayl currencyFound; } + private static > int nullsFirstCompare(T a, T b) { + return Comparator.nullsFirst(Comparator.naturalOrder()).compare(a, b); + } + + @Override + public int compareTo(@NotNull ComparableExt o) { + if (this == o) { + return 0; + } + if (!(o instanceof TradeStatistics3)) { + return -o.compareTo(this); + } + TradeStatistics3 that = (TradeStatistics3) o; + return date != that.date ? Long.compare(date, that.date) + : amount != that.amount ? Long.compare(amount, that.amount) + : !Objects.equals(currency, that.currency) ? nullsFirstCompare(currency, that.currency) + : price != that.price ? Long.compare(price, that.price) + : !Objects.equals(paymentMethod, that.paymentMethod) ? nullsFirstCompare(paymentMethod, that.paymentMethod) + : Arrays.compare(hash, that.hash); + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -482,9 +512,8 @@ public final class TradeStatistics3 implements ProcessOncePersistableNetworkPayl if (price != that.price) return false; if (amount != that.amount) return false; if (date != that.date) return false; - if (currency != null ? !currency.equals(that.currency) : that.currency != null) return false; - if (paymentMethod != null ? !paymentMethod.equals(that.paymentMethod) : that.paymentMethod != null) - return false; + if (!Objects.equals(currency, that.currency)) return false; + if (!Objects.equals(paymentMethod, that.paymentMethod)) return false; return Arrays.equals(hash, that.hash); } diff --git a/core/src/main/java/bisq/core/trade/statistics/TradeStatisticsManager.java b/core/src/main/java/bisq/core/trade/statistics/TradeStatisticsManager.java index 9a830e5c8f..6c250f8c6e 100644 --- a/core/src/main/java/bisq/core/trade/statistics/TradeStatisticsManager.java +++ b/core/src/main/java/bisq/core/trade/statistics/TradeStatisticsManager.java @@ -47,11 +47,13 @@ import java.time.Instant; import java.io.File; import java.util.ArrayList; -import java.util.Comparator; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.NavigableSet; import java.util.Set; +import java.util.TreeSet; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -68,7 +70,8 @@ public class TradeStatisticsManager { private final TradeStatisticsConverter tradeStatisticsConverter; private final File storageDir; private final boolean dumpStatistics; - private final ObservableSet observableTradeStatisticsSet = FXCollections.observableSet(); + private final NavigableSet navigableTradeStatisticsSet = new TreeSet<>(); + private final ObservableSet observableTradeStatisticsSet = FXCollections.observableSet(navigableTradeStatisticsSet); private JsonFileManager jsonFileManager; @Inject @@ -110,39 +113,24 @@ public class TradeStatisticsManager { } }); - Set set = tradeStatistics3StorageService.getMapOfAllData().values().stream() + tradeStatistics3StorageService.getMapOfAllData().values().stream() .filter(e -> e instanceof TradeStatistics3) .map(e -> (TradeStatistics3) e) .filter(TradeStatistics3::isValid) - .collect(Collectors.toSet()); - observableTradeStatisticsSet.addAll(set); + .forEach(observableTradeStatisticsSet::add); - // collate prices by ccy -- takes about 10 ms for 5000 items - Map> allPriceByCurrencyCode = new HashMap<>(); - observableTradeStatisticsSet.forEach(e -> { - List list; - String currencyCode = e.getCurrency(); - if (allPriceByCurrencyCode.containsKey(currencyCode)) { - list = allPriceByCurrencyCode.get(currencyCode); - } else { - list = new ArrayList<>(); - allPriceByCurrencyCode.put(currencyCode, list); - } - list.add(e); - }); // get the most recent price for each ccy and notify priceFeedService + // (this relies on the trade statistics set being sorted by date) Map newestPriceByCurrencyCode = new HashMap<>(); - allPriceByCurrencyCode.values().stream() - .filter(list -> !list.isEmpty()) - .forEach(list -> { - list.sort(Comparator.comparing(TradeStatistics3::getDate)); - TradeStatistics3 tradeStatistics = list.get(list.size() - 1); - newestPriceByCurrencyCode.put(tradeStatistics.getCurrency(), tradeStatistics.getTradePrice()); - }); + observableTradeStatisticsSet.forEach(e -> newestPriceByCurrencyCode.put(e.getCurrency(), e.getTradePrice())); priceFeedService.applyInitialBisqMarketPrice(newestPriceByCurrencyCode); maybeDumpStatistics(); } + public NavigableSet getNavigableTradeStatisticsSet() { + return Collections.unmodifiableNavigableSet(navigableTradeStatisticsSet); + } + public ObservableSet getObservableTradeStatisticsSet() { return observableTradeStatisticsSet; } @@ -170,7 +158,7 @@ public class TradeStatisticsManager { Instant yearAgo = Instant.ofEpochSecond(Instant.now().getEpochSecond() - TimeUnit.DAYS.toSeconds(365)); Set activeCurrencies = observableTradeStatisticsSet.stream() .filter(e -> e.getDate().toInstant().isAfter(yearAgo)) - .map(p -> p.getCurrency()) + .map(TradeStatistics3::getCurrency) .collect(Collectors.toSet()); ArrayList activeFiatCurrencyList = fiatCurrencyList.stream() diff --git a/core/src/main/java/bisq/core/util/AveragePriceUtil.java b/core/src/main/java/bisq/core/util/AveragePriceUtil.java index fd1f8e65df..631719c26c 100644 --- a/core/src/main/java/bisq/core/util/AveragePriceUtil.java +++ b/core/src/main/java/bisq/core/util/AveragePriceUtil.java @@ -24,16 +24,21 @@ import bisq.core.trade.statistics.TradeStatisticsManager; import bisq.core.user.Preferences; import bisq.common.util.MathUtils; +import bisq.common.util.RangeUtils; import bisq.common.util.Tuple2; import org.bitcoinj.utils.Fiat; +import com.google.common.collect.Range; +import com.google.common.primitives.Doubles; + import java.util.ArrayList; import java.util.Calendar; -import java.util.Comparator; import java.util.Date; import java.util.GregorianCalendar; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; public class AveragePriceUtil { @@ -58,23 +63,21 @@ public class AveragePriceUtil { Date pastXDays, Date date) { double percentToTrim = Math.max(0, Math.min(49, preferences.getBsqAverageTrimThreshold() * 100)); - List bsqAllTradePastXDays = tradeStatisticsManager.getObservableTradeStatisticsSet().stream() - .filter(e -> e.getCurrency().equals("BSQ")) - .filter(e -> e.getDate().after(pastXDays)) - .filter(e -> e.getDate().before(date)) - .collect(Collectors.toList()); - List bsqTradePastXDays = percentToTrim > 0 ? - removeOutliers(bsqAllTradePastXDays, percentToTrim) : - bsqAllTradePastXDays; + Set allTradePastXDays = RangeUtils.subSet(tradeStatisticsManager.getNavigableTradeStatisticsSet()) + .withKey(TradeStatistics3::getDate) + .overRange(Range.open(pastXDays, date)); + + Map> bsqUsdAllTradePastXDays = allTradePastXDays.stream() + .filter(e -> e.getCurrency().equals("USD") || e.getCurrency().equals("BSQ")) + .collect(Collectors.partitioningBy(e -> e.getCurrency().equals("USD"))); + + List bsqTradePastXDays = percentToTrim > 0 ? + removeOutliers(bsqUsdAllTradePastXDays.get(false), percentToTrim) : + bsqUsdAllTradePastXDays.get(false); - List usdAllTradePastXDays = tradeStatisticsManager.getObservableTradeStatisticsSet().stream() - .filter(e -> e.getCurrency().equals("USD")) - .filter(e -> e.getDate().after(pastXDays)) - .filter(e -> e.getDate().before(date)) - .collect(Collectors.toList()); List usdTradePastXDays = percentToTrim > 0 ? - removeOutliers(usdAllTradePastXDays, percentToTrim) : - usdAllTradePastXDays; + removeOutliers(bsqUsdAllTradePastXDays.get(true), percentToTrim) : + bsqUsdAllTradePastXDays.get(true); Price usdPrice = Price.valueOf("USD", getUSDAverage(bsqTradePastXDays, usdTradePastXDays)); Price bsqPrice = Price.valueOf("BSQ", getBTCAverage(bsqTradePastXDays)); @@ -82,17 +85,17 @@ public class AveragePriceUtil { } private static List removeOutliers(List list, double percentToTrim) { - List yValues = list.stream() + List yValues = Doubles.asList(list.stream() .filter(TradeStatistics3::isValid) - .map(e -> (double) e.getPrice()) - .collect(Collectors.toList()); + .mapToDouble(TradeStatistics3::getPrice) + .toArray()); Tuple2 tuple = InlierUtil.findInlierRange(yValues, percentToTrim, HOW_MANY_STD_DEVS_CONSTITUTE_OUTLIER); double lowerBound = tuple.first; double upperBound = tuple.second; return list.stream() - .filter(e -> e.getPrice() > lowerBound) - .filter(e -> e.getPrice() < upperBound) + .filter(e -> (double) e.getPrice() >= lowerBound) + .filter(e -> (double) e.getPrice() <= upperBound) .collect(Collectors.toList()); } @@ -111,21 +114,23 @@ public class AveragePriceUtil { return averagePrice; } - private static long getUSDAverage(List bsqList, List usdList) { + private static long getUSDAverage(List sortedBsqList, List sortedUsdList) { // Use next USD/BTC print as price to calculate BSQ/USD rate // Store each trade as amount of USD and amount of BSQ traded - List> usdBsqList = new ArrayList<>(bsqList.size()); - usdList.sort(Comparator.comparing(TradeStatistics3::getDateAsLong)); + List> usdBsqList = new ArrayList<>(sortedBsqList.size()); var usdBTCPrice = 10000d; // Default to 10000 USD per BTC if there is no USD feed at all - for (TradeStatistics3 item : bsqList) { + int i = 0; + for (TradeStatistics3 item : sortedBsqList) { // Find usd price for trade item - usdBTCPrice = usdList.stream() - .filter(usd -> usd.getDateAsLong() > item.getDateAsLong()) - .map(usd -> MathUtils.scaleDownByPowerOf10((double) usd.getTradePrice().getValue(), - Fiat.SMALLEST_UNIT_EXPONENT)) - .findFirst() - .orElse(usdBTCPrice); + for (; i < sortedUsdList.size(); i++) { + TradeStatistics3 usd = sortedUsdList.get(i); + if (usd.getDateAsLong() > item.getDateAsLong()) { + usdBTCPrice = MathUtils.scaleDownByPowerOf10((double) usd.getTradePrice().getValue(), + Fiat.SMALLEST_UNIT_EXPONENT); + break; + } + } var bsqAmount = MathUtils.scaleDownByPowerOf10((double) item.getTradeVolume().getValue(), Altcoin.SMALLEST_UNIT_EXPONENT); var btcAmount = MathUtils.scaleDownByPowerOf10((double) item.getTradeAmount().getValue(), diff --git a/core/src/main/java/bisq/core/util/InlierUtil.java b/core/src/main/java/bisq/core/util/InlierUtil.java index 41aefac866..96bc8067b3 100644 --- a/core/src/main/java/bisq/core/util/InlierUtil.java +++ b/core/src/main/java/bisq/core/util/InlierUtil.java @@ -20,11 +20,15 @@ package bisq.core.util; import bisq.common.util.DoubleSummaryStatisticsWithStdDev; import bisq.common.util.Tuple2; -import javafx.collections.FXCollections; +import com.google.common.primitives.Doubles; +import java.util.Arrays; import java.util.DoubleSummaryStatistics; import java.util.List; -import java.util.stream.Collectors; +import java.util.Spliterator; +import java.util.function.DoublePredicate; +import java.util.stream.DoubleStream; +import java.util.stream.StreamSupport; public class InlierUtil { @@ -39,12 +43,9 @@ public class InlierUtil { Tuple2 inlierThreshold = computeInlierThreshold(yValues, percentToTrim, howManyStdDevsConstituteOutlier); - DoubleSummaryStatistics inlierStatistics = - yValues - .stream() - .filter(y -> withinBounds(inlierThreshold, y)) - .mapToDouble(Double::doubleValue) - .summaryStatistics(); + DoubleSummaryStatistics inlierStatistics = stream(yValues) + .filter(withinBounds(inlierThreshold)) + .summaryStatistics(); var inlierMin = inlierStatistics.getMin(); var inlierMax = inlierStatistics.getMax(); @@ -52,10 +53,10 @@ public class InlierUtil { return new Tuple2<>(inlierMin, inlierMax); } - private static boolean withinBounds(Tuple2 bounds, double number) { - var lowerBound = bounds.first; - var upperBound = bounds.second; - return (lowerBound <= number) && (number <= upperBound); + private static DoublePredicate withinBounds(Tuple2 bounds) { + double lowerBound = bounds.first; + double upperBound = bounds.second; + return number -> lowerBound <= number && number <= upperBound; } /* Computes the lower and upper inlier thresholds. A point lying outside @@ -75,12 +76,10 @@ public class InlierUtil { List trimmed = trim(percentToTrim, numbers); - DoubleSummaryStatisticsWithStdDev summaryStatistics = - trimmed.stream() - .collect( - DoubleSummaryStatisticsWithStdDev::new, - DoubleSummaryStatisticsWithStdDev::accept, - DoubleSummaryStatisticsWithStdDev::combine); + DoubleSummaryStatisticsWithStdDev summaryStatistics = stream(trimmed) + .collect(DoubleSummaryStatisticsWithStdDev::new, + DoubleSummaryStatisticsWithStdDev::accept, + DoubleSummaryStatisticsWithStdDev::combine); double mean = summaryStatistics.getAverage(); double stdDev = summaryStatistics.getStandardDeviation(); @@ -111,7 +110,7 @@ public class InlierUtil { return numbers; } if (totalPercentTrim == 100) { - return FXCollections.emptyObservableList(); + return Doubles.asList(); } if (numbers.isEmpty()) { @@ -124,17 +123,17 @@ public class InlierUtil { return numbers; } - var sorted = numbers.stream().sorted(); + var array = stream(numbers).toArray(); + Arrays.sort(array); - var oneSideTrimmed = sorted.skip(countToDropFromEachSide); - - // Here, having already trimmed the left-side, we are implicitly trimming - // the right-side by specifying a limit to the stream's length. - // An explicit right-side drop/trim/skip is not supported by the Stream API. - var countAfterTrim = count - (countToDropFromEachSide * 2); // visada > 0? ir <= count? - var bothSidesTrimmed = oneSideTrimmed.limit(countAfterTrim); - - return bothSidesTrimmed.collect(Collectors.toList()); + var sorted = Doubles.asList(array); + return sorted.subList(countToDropFromEachSide, sorted.size() - countToDropFromEachSide); } + private static DoubleStream stream(Iterable doubles) { + var spliterator = doubles.spliterator(); + return spliterator instanceof Spliterator.OfDouble + ? StreamSupport.doubleStream((Spliterator.OfDouble) spliterator, false) + : StreamSupport.stream(spliterator, false).mapToDouble(Double::doubleValue); + } } diff --git a/core/src/main/java/bisq/core/util/VolumeUtil.java b/core/src/main/java/bisq/core/util/VolumeUtil.java index 769e2dc9eb..9a102a05d5 100644 --- a/core/src/main/java/bisq/core/util/VolumeUtil.java +++ b/core/src/main/java/bisq/core/util/VolumeUtil.java @@ -19,14 +19,10 @@ package bisq.core.util; import bisq.core.locale.Res; import bisq.core.monetary.Altcoin; -import bisq.core.monetary.AltcoinExchangeRate; -import bisq.core.monetary.Price; import bisq.core.monetary.Volume; import bisq.core.offer.Offer; -import org.bitcoinj.core.Coin; import org.bitcoinj.core.Monetary; -import org.bitcoinj.utils.ExchangeRate; import org.bitcoinj.utils.Fiat; import org.bitcoinj.utils.MonetaryFormat; @@ -66,14 +62,6 @@ public class VolumeUtil { return Volume.parse(String.valueOf(roundedVolume), volumeByAmount.getCurrencyCode()); } - public static Volume getVolume(Coin amount, Price price) { - if (price.getMonetary() instanceof Altcoin) { - return new Volume(new AltcoinExchangeRate((Altcoin) price.getMonetary()).coinToAltcoin(amount)); - } else { - return new Volume(new ExchangeRate((Fiat) price.getMonetary()).coinToFiat(amount)); - } - } - public static String formatVolume(Offer offer, Boolean decimalAligned, int maxNumberOfDigits) { return formatVolume(offer, decimalAligned, maxNumberOfDigits, true); diff --git a/core/src/test/java/bisq/core/trade/statistics/TradeStatistics3Test.java b/core/src/test/java/bisq/core/trade/statistics/TradeStatistics3Test.java new file mode 100644 index 0000000000..2ed41a6196 --- /dev/null +++ b/core/src/test/java/bisq/core/trade/statistics/TradeStatistics3Test.java @@ -0,0 +1,47 @@ +/* + * 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.core.trade.statistics; + +import bisq.core.payment.payload.PaymentMethod; + +import com.google.common.collect.Sets; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class TradeStatistics3Test { + @Disabled("Not fixed yet") + @Test + public void allPaymentMethodsCoveredByWrapper() { + Set paymentMethodCodes = PaymentMethod.getPaymentMethods().stream() + .map(PaymentMethod::getId) + .collect(Collectors.toSet()); + + Set wrapperCodes = Arrays.stream(TradeStatistics3.PaymentMethodMapper.values()) + .map(Enum::name) + .collect(Collectors.toSet()); + + assertEquals(Set.of(), Sets.difference(paymentMethodCodes, wrapperCodes)); + } +} diff --git a/desktop/src/main/java/bisq/desktop/components/chart/ChartDataModel.java b/desktop/src/main/java/bisq/desktop/components/chart/ChartDataModel.java index 0b3399f39e..18875498bc 100644 --- a/desktop/src/main/java/bisq/desktop/components/chart/ChartDataModel.java +++ b/desktop/src/main/java/bisq/desktop/components/chart/ChartDataModel.java @@ -22,10 +22,10 @@ import bisq.desktop.common.model.ActivatableDataModel; import java.time.Instant; import java.time.temporal.TemporalAdjuster; -import java.util.Comparator; import java.util.Map; import java.util.function.BinaryOperator; import java.util.function.Predicate; +import java.util.function.ToLongFunction; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -64,6 +64,11 @@ public abstract class ChartDataModel extends ActivatableDataModel { return temporalAdjusterModel.toTimeInterval(instant); } + // optimized for use when the input times are sequential and not too spread out + public ToLongFunction toCachedTimeIntervalFn() { + return temporalAdjusterModel.withCache()::toTimeInterval; + } + /////////////////////////////////////////////////////////////////////////////////////////// // Date filter predicate @@ -88,7 +93,7 @@ public abstract class ChartDataModel extends ActivatableDataModel { Map map2, BinaryOperator mergeFunction) { return Stream.concat(map1.entrySet().stream(), - map2.entrySet().stream()) + map2.entrySet().stream()) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, mergeFunction)); diff --git a/desktop/src/main/java/bisq/desktop/components/chart/TemporalAdjusterModel.java b/desktop/src/main/java/bisq/desktop/components/chart/TemporalAdjusterModel.java index d8cf5c3bb8..0126a1b450 100644 --- a/desktop/src/main/java/bisq/desktop/components/chart/TemporalAdjusterModel.java +++ b/desktop/src/main/java/bisq/desktop/components/chart/TemporalAdjusterModel.java @@ -18,6 +18,7 @@ package bisq.desktop.components.chart; import bisq.common.util.MathUtils; +import bisq.common.util.Tuple3; import java.time.DayOfWeek; import java.time.Instant; @@ -37,6 +38,19 @@ import static java.time.temporal.ChronoField.DAY_OF_YEAR; public class TemporalAdjusterModel { private static final ZoneId ZONE_ID = ZoneId.systemDefault(); + protected TemporalAdjuster temporalAdjuster = Interval.MONTH.getAdjuster(); + + private boolean enableCache; + private Tuple3 cachedDateStartEndTuple; + private Tuple3 cachedTimeIntervalMapping; + + public TemporalAdjusterModel withCache() { + var model = new TemporalAdjusterModel(); + model.temporalAdjuster = this.temporalAdjuster; + model.enableCache = true; + return model; + } + public enum Interval { YEAR(TemporalAdjusters.firstDayOfYear()), HALF_YEAR(temporal -> { @@ -81,8 +95,6 @@ public class TemporalAdjusterModel { } } - protected TemporalAdjuster temporalAdjuster = Interval.MONTH.getAdjuster(); - public void setTemporalAdjuster(TemporalAdjuster temporalAdjuster) { this.temporalAdjuster = temporalAdjuster; } @@ -96,12 +108,36 @@ public class TemporalAdjusterModel { } public long toTimeInterval(Instant instant, TemporalAdjuster temporalAdjuster) { - return instant - .atZone(ZONE_ID) - .toLocalDate() - .with(temporalAdjuster) - .atStartOfDay(ZONE_ID) - .toInstant() - .getEpochSecond(); + LocalDate date = toLocalDate(instant); + Tuple3 tuple = cachedTimeIntervalMapping; + long timeInterval; + if (tuple != null && date.equals(tuple.first) && temporalAdjuster.equals(tuple.second)) { + timeInterval = tuple.third; + } else { + timeInterval = date + .with(temporalAdjuster) + .atStartOfDay(ZONE_ID) + .toEpochSecond(); + if (enableCache) { + cachedTimeIntervalMapping = new Tuple3<>(date, temporalAdjuster, timeInterval); + } + } + return timeInterval; + } + + private LocalDate toLocalDate(Instant instant) { + LocalDate date; + Tuple3 tuple = cachedDateStartEndTuple; + if (tuple != null && !instant.isBefore(tuple.second) && instant.isBefore(tuple.third)) { + date = tuple.first; + } else { + date = instant.atZone(ZONE_ID).toLocalDate(); + if (enableCache) { + cachedDateStartEndTuple = new Tuple3<>(date, + date.atStartOfDay(ZONE_ID).toInstant(), + date.plusDays(1).atStartOfDay(ZONE_ID).toInstant()); + } + } + return date; } } diff --git a/desktop/src/main/java/bisq/desktop/main/dao/economy/dashboard/price/PriceChartDataModel.java b/desktop/src/main/java/bisq/desktop/main/dao/economy/dashboard/price/PriceChartDataModel.java index 49b2a0a7b4..7341481d87 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/economy/dashboard/price/PriceChartDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/economy/dashboard/price/PriceChartDataModel.java @@ -188,9 +188,10 @@ public class PriceChartDataModel extends ChartDataModel { private Map getPriceByInterval(Predicate collectionFilter, Function, Double> getAveragePriceFunction) { + var toTimeIntervalFn = toCachedTimeIntervalFn(); return getPriceByInterval(tradeStatisticsManager.getObservableTradeStatisticsSet(), collectionFilter, - tradeStatistics -> toTimeInterval(Instant.ofEpochMilli(tradeStatistics.getDateAsLong())), + tradeStatistics -> toTimeIntervalFn.applyAsLong(Instant.ofEpochMilli(tradeStatistics.getDateAsLong())), dateFilter, getAveragePriceFunction); } diff --git a/desktop/src/main/java/bisq/desktop/main/dao/economy/dashboard/volume/VolumeChartDataModel.java b/desktop/src/main/java/bisq/desktop/main/dao/economy/dashboard/volume/VolumeChartDataModel.java index e147ce6977..3ec89998d9 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/economy/dashboard/volume/VolumeChartDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/economy/dashboard/volume/VolumeChartDataModel.java @@ -134,8 +134,9 @@ public class VolumeChartDataModel extends ChartDataModel { /////////////////////////////////////////////////////////////////////////////////////////// private Map getVolumeByInterval(Function, Long> getVolumeFunction) { + var toTimeIntervalFn = toCachedTimeIntervalFn(); return getVolumeByInterval(tradeStatisticsManager.getObservableTradeStatisticsSet(), - tradeStatistics -> toTimeInterval(Instant.ofEpochMilli(tradeStatistics.getDateAsLong())), + tradeStatistics -> toTimeIntervalFn.applyAsLong(Instant.ofEpochMilli(tradeStatistics.getDateAsLong())), dateFilter, getVolumeFunction); } diff --git a/desktop/src/main/java/bisq/desktop/main/dao/economy/supply/dao/DaoChartDataModel.java b/desktop/src/main/java/bisq/desktop/main/dao/economy/supply/dao/DaoChartDataModel.java index 1ce0ed17fe..31df6cea73 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/economy/supply/dao/DaoChartDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/economy/supply/dao/DaoChartDataModel.java @@ -45,6 +45,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; +import java.util.stream.Stream; import lombok.extern.slf4j.Slf4j; @@ -76,7 +77,6 @@ public class DaoChartDataModel extends ChartDataModel { this.daoStateService = daoStateService; - // TODO getBlockTime is the bottleneck. Add a lookup map to daoState to fix that in a dedicated PR. blockTimeOfIssuanceFunction = memoize(issuance -> { int height = daoStateService.getStartHeightOfCurrentCycle(issuance.getChainHeight()).orElse(0); return daoStateService.getBlockTime(height); @@ -218,7 +218,7 @@ public class DaoChartDataModel extends ChartDataModel { return totalBurnedByInterval; } - totalBurnedByInterval = getBurntBsqByInterval(daoStateService.getBurntFeeTxs(), getDateFilter()); + totalBurnedByInterval = getBurntBsqByInterval(getBurntFeeTxStream(), getDateFilter()); return totalBurnedByInterval; } @@ -227,7 +227,7 @@ public class DaoChartDataModel extends ChartDataModel { return bsqTradeFeeByInterval; } - bsqTradeFeeByInterval = getBurntBsqByInterval(daoStateService.getTradeFeeTxs(), getDateFilter()); + bsqTradeFeeByInterval = getBurntBsqByInterval(getTradeFeeTxStream(), getDateFilter()); return bsqTradeFeeByInterval; } @@ -247,7 +247,7 @@ public class DaoChartDataModel extends ChartDataModel { return proofOfBurnByInterval; } - proofOfBurnByInterval = getBurntBsqByInterval(daoStateService.getProofOfBurnTxs(), getDateFilter()); + proofOfBurnByInterval = getBurntBsqByInterval(daoStateService.getProofOfBurnTxs().stream(), getDateFilter()); return proofOfBurnByInterval; } @@ -278,10 +278,9 @@ public class DaoChartDataModel extends ChartDataModel { // Tagging started Nov 2021 // opReturn data from BTC fees: 1701721206fe6b40777763de1c741f4fd2706d94775d Set proofOfBurnTxs = daoStateService.getProofOfBurnTxs(); - Set feeTxs = proofOfBurnTxs.stream() - .filter(tx -> "1701721206fe6b40777763de1c741f4fd2706d94775d".equals(Hex.encode(tx.getLastTxOutput().getOpReturnData()))) - .collect(Collectors.toSet()); - proofOfBurnFromBtcFeesByInterval = getBurntBsqByInterval(feeTxs, getDateFilter()); + Stream feeTxStream = proofOfBurnTxs.stream() + .filter(tx -> "1701721206fe6b40777763de1c741f4fd2706d94775d".equals(Hex.encode(tx.getLastTxOutput().getOpReturnData()))); + proofOfBurnFromBtcFeesByInterval = getBurntBsqByInterval(feeTxStream, getDateFilter()); return proofOfBurnFromBtcFeesByInterval; } @@ -293,11 +292,10 @@ public class DaoChartDataModel extends ChartDataModel { // Tagging started Nov 2021 // opReturn data from delayed payout txs: 1701e47e5d8030f444c182b5e243871ebbaeadb5e82f // opReturn data from BM trades with a trade who got reimbursed by the DAO : 1701293c488822f98e70e047012f46f5f1647f37deb7 - Set feeTxs = daoStateService.getProofOfBurnTxs().stream() + Stream feeTxStream = daoStateService.getProofOfBurnTxs().stream() .filter(e -> "1701e47e5d8030f444c182b5e243871ebbaeadb5e82f".equals(Hex.encode(e.getLastTxOutput().getOpReturnData())) || - "1701293c488822f98e70e047012f46f5f1647f37deb7".equals(Hex.encode(e.getLastTxOutput().getOpReturnData()))) - .collect(Collectors.toSet()); - proofOfBurnFromArbitrationByInterval = getBurntBsqByInterval(feeTxs, getDateFilter()); + "1701293c488822f98e70e047012f46f5f1647f37deb7".equals(Hex.encode(e.getLastTxOutput().getOpReturnData()))); + proofOfBurnFromArbitrationByInterval = getBurntBsqByInterval(feeTxStream, getDateFilter()); return proofOfBurnFromArbitrationByInterval; } @@ -310,7 +308,7 @@ public class DaoChartDataModel extends ChartDataModel { Collection issuanceSetForType = daoStateService.getIssuanceItems(); // get all issued and burnt BSQ, not just the filtered date range Map tmpIssuedByInterval = getIssuedBsqByInterval(issuanceSetForType, e -> true); - Map tmpBurnedByInterval = new TreeMap<>(getBurntBsqByInterval(daoStateService.getBurntFeeTxs(), e -> true) + Map tmpBurnedByInterval = new TreeMap<>(getBurntBsqByInterval(getBurntFeeTxStream(), e -> true) .entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, e -> -e.getValue()))); Map tmpSupplyByInterval = getMergedMap(tmpIssuedByInterval, tmpBurnedByInterval, Long::sum); @@ -367,9 +365,10 @@ public class DaoChartDataModel extends ChartDataModel { Long::sum)); } - private Map getBurntBsqByInterval(Collection txs, Predicate dateFilter) { - return txs.stream() - .collect(Collectors.groupingBy(tx -> toTimeInterval(Instant.ofEpochMilli(tx.getTime())))) + private Map getBurntBsqByInterval(Stream txStream, Predicate dateFilter) { + var toTimeIntervalFn = toCachedTimeIntervalFn(); + return txStream + .collect(Collectors.groupingBy(tx -> toTimeIntervalFn.applyAsLong(Instant.ofEpochMilli(tx.getTime())))) .entrySet() .stream() .filter(entry -> dateFilter.test(entry.getKey())) @@ -384,6 +383,20 @@ public class DaoChartDataModel extends ChartDataModel { return date -> date >= TAG_DATE.getTimeInMillis() / 1000; // we use seconds } + // TODO: Consider moving these two methods to DaoStateService: + + private Stream getBurntFeeTxStream() { + return daoStateService.getBlocks().stream() + .flatMap(b -> b.getTxs().stream()) + .filter(tx -> tx.getBurntFee() > 0); + } + + private Stream getTradeFeeTxStream() { + return daoStateService.getBlocks().stream() + .flatMap(b -> b.getTxs().stream()) + .filter(tx -> tx.getTxType() == TxType.PAY_TRADE_FEE); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Utils 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 index 3c720ee48d..ac1d6815e3 100644 --- a/desktop/src/main/java/bisq/desktop/main/market/trades/ChartCalculations.java +++ b/desktop/src/main/java/bisq/desktop/main/market/trades/ChartCalculations.java @@ -17,6 +17,7 @@ package bisq.desktop.main.market.trades; +import bisq.desktop.main.market.trades.TradesChartsViewModel.TickUnit; import bisq.desktop.main.market.trades.charts.CandleData; import bisq.desktop.util.DisplayUtils; @@ -25,37 +26,42 @@ import bisq.core.monetary.Altcoin; import bisq.core.trade.statistics.TradeStatistics3; import bisq.common.util.MathUtils; +import bisq.common.util.Tuple2; import org.bitcoinj.core.Coin; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableSortedSet; import javafx.scene.chart.XYChart; import javafx.util.Pair; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; +import java.util.Arrays; 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.NavigableSet; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; +import java.util.stream.IntStream; import lombok.Getter; import static bisq.desktop.main.market.trades.TradesChartsViewModel.MAX_TICKS; public class ChartCalculations { + @VisibleForTesting static final ZoneId ZONE_ID = ZoneId.systemDefault(); @@ -63,29 +69,35 @@ public class ChartCalculations { // Async /////////////////////////////////////////////////////////////////////////////////////////// - static CompletableFuture>> getUsdAveragePriceMapsPerTickUnit(Set tradeStatisticsSet) { + static CompletableFuture>> getUsdAveragePriceMapsPerTickUnit(NavigableSet sortedTradeStatisticsSet) { return CompletableFuture.supplyAsync(() -> { - Map> usdAveragePriceMapsPerTickUnit = new HashMap<>(); - Map>> dateMapsPerTickUnit = new HashMap<>(); - for (TradesChartsViewModel.TickUnit tick : TradesChartsViewModel.TickUnit.values()) { - dateMapsPerTickUnit.put(tick, new HashMap<>()); + Map> priceAccumulatorMapsPerTickUnit = new HashMap<>(); + for (TickUnit tick : TickUnit.values()) { + priceAccumulatorMapsPerTickUnit.put(tick, new HashMap<>()); } - tradeStatisticsSet.stream() + // Stream the trade statistics in reverse chronological order + TickUnit[] tickUnits = TickUnit.values(); + sortedTradeStatisticsSet.descendingSet().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); + for (TickUnit tickUnit : tickUnits) { + Map map = priceAccumulatorMapsPerTickUnit.get(tickUnit); + if (map.size() > MAX_TICKS) { + // No more prices are needed once more than MAX_TICKS candles have been spanned + // (and tick size is decreasing so we may break out of the whole loop) + break; + } + long time = roundToTick(tradeStatistics.getLocalDateTime(), tickUnit).getTime(); + map.computeIfAbsent(time, t -> new PriceAccumulator()).add(tradeStatistics); } }); - dateMapsPerTickUnit.forEach((tick, map) -> { + Map> usdAveragePriceMapsPerTickUnit = new HashMap<>(); + priceAccumulatorMapsPerTickUnit.forEach((tickUnit, map) -> { HashMap priceMap = new HashMap<>(); - map.forEach((date, tradeStatisticsList) -> priceMap.put(date, getAveragePrice(tradeStatisticsList))); - usdAveragePriceMapsPerTickUnit.put(tick, priceMap); + map.forEach((date, accumulator) -> priceMap.put(date, accumulator.getAveragePrice())); + usdAveragePriceMapsPerTickUnit.put(tickUnit, priceMap); }); return usdAveragePriceMapsPerTickUnit; }); @@ -94,34 +106,33 @@ public class ChartCalculations { 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()); - }); + return CompletableFuture.supplyAsync(() -> tradeStatisticsSet.stream() + .filter(e -> showAllTradeCurrencies || e.getCurrency().equals(currencyCode)) + .collect(Collectors.toList())); } static CompletableFuture getUpdateChartResult(List tradeStatisticsByCurrency, - TradesChartsViewModel.TickUnit tickUnit, - Map> usdAveragePriceMapsPerTickUnit, + TickUnit tickUnit, + Map> usdAveragePriceMapsPerTickUnit, String currencyCode) { return CompletableFuture.supplyAsync(() -> { // Generate date range and create sets for all ticks - Map>> itemsPerInterval = getItemsPerInterval(tradeStatisticsByCurrency, tickUnit); + List>> 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(); + List candleDataList = IntStream.range(0, itemsPerInterval.size()) + .filter(i -> !itemsPerInterval.get(i).getValue().isEmpty()) + .mapToObj(i -> { + Pair> pair = itemsPerInterval.get(i); + long tickStartDate = pair.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); + return getCandleData(i, pair.getValue(), averageUsdPrice.get(), tickUnit, currencyCode, itemsPerInterval); }) .sorted(Comparator.comparingLong(o -> o.tick)) .collect(Collectors.toList()); @@ -144,12 +155,12 @@ public class ChartCalculations { @Getter static class UpdateChartResult { - private final Map>> itemsPerInterval; + private final List>> itemsPerInterval; private final List> priceItems; private final List> volumeItems; private final List> volumeInUsdItems; - public UpdateChartResult(Map>> itemsPerInterval, + public UpdateChartResult(List>> itemsPerInterval, List> priceItems, List> volumeItems, List> volumeInUsdItems) { @@ -166,76 +177,92 @@ public class ChartCalculations { // Private /////////////////////////////////////////////////////////////////////////////////////////// - static Map>> getItemsPerInterval(List tradeStatisticsByCurrency, - TradesChartsViewModel.TickUnit tickUnit) { - // Generate date range and create sets for all ticks - Map>> itemsPerInterval = new HashMap<>(); + static List>> getItemsPerInterval(List tradeStatisticsByCurrency, + TickUnit tickUnit) { + // Generate date range and create lists for all ticks + List>> itemsPerInterval = new ArrayList<>(Collections.nCopies(MAX_TICKS + 2, null)); 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); + for (int i = MAX_TICKS + 1; i >= 0; --i) { + Pair> pair = new Pair<>((Date) time.clone(), new ArrayList<>()); + itemsPerInterval.set(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); + int i = MAX_TICKS; + for (TradeStatistics3 tradeStatistics : tradeStatisticsByCurrency) { + // Start from the last used tick index - move forwards if necessary + for (; i < MAX_TICKS; i++) { + Pair> pair = itemsPerInterval.get(i + 1); + if (!tradeStatistics.getDate().after(pair.getKey())) { + break; + } + } + // Scan backwards until the correct tick is reached + for (; i > 0; --i) { + Pair> pair = itemsPerInterval.get(i); if (tradeStatistics.getDate().after(pair.getKey())) { pair.getValue().add(tradeStatistics); break; } } - }); - return itemsPerInterval; + } + // Convert the lists into sorted sets + return itemsPerInterval.stream() + .map(pair -> new Pair<>(pair.getKey(), (Set) ImmutableSortedSet.copyOf(pair.getValue()))) + .collect(Collectors.toList()); } - static Date roundToTick(LocalDateTime localDate, TradesChartsViewModel.TickUnit tickUnit) { + private static LocalDateTime roundToTickAsLocalDateTime(LocalDateTime localDateTime, + 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()); + return localDateTime.withMonth(1).withDayOfYear(1).toLocalDate().atStartOfDay(); case MONTH: - return Date.from(localDate.withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); + return localDateTime.withDayOfMonth(1).toLocalDate().atStartOfDay(); 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()); + int dayOfWeek = localDateTime.getDayOfWeek().getValue(); + LocalDate firstDayOfWeek = localDateTime.toLocalDate().minusDays(dayOfWeek - 1); + return firstDayOfWeek.atStartOfDay(); case DAY: - return Date.from(localDate.withHour(0).withMinute(0).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); + return localDateTime.toLocalDate().atStartOfDay(); case HOUR: - return Date.from(localDate.withMinute(0).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); + return localDateTime.withMinute(0).withSecond(0).withNano(0); case MINUTE_10: - return Date.from(localDate.withMinute(localDate.getMinute() - localDate.getMinute() % 10).withSecond(0).withNano(0).atZone(ZONE_ID).toInstant()); + return localDateTime.withMinute(localDateTime.getMinute() - localDateTime.getMinute() % 10).withSecond(0).withNano(0); default: - return Date.from(localDate.atZone(ZONE_ID).toInstant()); + return localDateTime; } } - static Date roundToTick(Date time, TradesChartsViewModel.TickUnit tickUnit) { - return roundToTick(time.toInstant().atZone(ChartCalculations.ZONE_ID).toLocalDateTime(), tickUnit); - } + // Use an array rather than an EnumMap here, since the latter is not thread safe - this gives benign races only: + private static final Tuple2[] cachedLocalDateTimeToDateMappings = new Tuple2[TickUnit.values().length]; - private static long getAveragePrice(List tradeStatisticsList) { - long accumulatedAmount = 0; - long accumulatedVolume = 0; - for (TradeStatistics3 tradeStatistics : tradeStatisticsList) { - accumulatedAmount += tradeStatistics.getAmount(); - accumulatedVolume += tradeStatistics.getTradeVolume().getValue(); + private static Date roundToTick(LocalDateTime localDateTime, TickUnit tickUnit) { + LocalDateTime rounded = roundToTickAsLocalDateTime(localDateTime, tickUnit); + // Benefits from caching last result (per tick unit) since trade statistics are pre-sorted by date + int i = tickUnit.ordinal(); + var tuple = cachedLocalDateTimeToDateMappings[i]; + if (tuple == null || !rounded.equals(tuple.first)) { + cachedLocalDateTimeToDateMappings[i] = tuple = new Tuple2<>(rounded, Date.from(rounded.atZone(ZONE_ID).toInstant())); } - - double accumulatedVolumeAsDouble = MathUtils.scaleUpByPowerOf10((double) accumulatedVolume, Coin.SMALLEST_UNIT_EXPONENT); - return MathUtils.roundDoubleToLong(accumulatedVolumeAsDouble / (double) accumulatedAmount); + return (Date) tuple.second; } @VisibleForTesting - static CandleData getCandleData(long tick, Set set, + static Date roundToTick(Date time, TickUnit tickUnit) { + return roundToTick(time.toInstant().atZone(ChartCalculations.ZONE_ID).toLocalDateTime(), tickUnit); + } + + @VisibleForTesting + static CandleData getCandleData(int tickIndex, Set set, long averageUsdPrice, - TradesChartsViewModel.TickUnit tickUnit, + TickUnit tickUnit, String currencyCode, - Map>> itemsPerInterval) { + List>> itemsPerInterval) { long open = 0; long close = 0; long high = 0; @@ -243,7 +270,9 @@ public class ChartCalculations { long accumulatedVolume = 0; long accumulatedAmount = 0; long numTrades = set.size(); - List tradePrices = new ArrayList<>(); + + int arrayIndex = 0; + long[] tradePrices = new long[set.size()]; for (TradeStatistics3 item : set) { long tradePriceAsLong = item.getTradePrice().getValue(); // Previously a check was done which inverted the low and high for cryptocurrencies. @@ -252,21 +281,18 @@ public class ChartCalculations { accumulatedVolume += item.getTradeVolume().getValue(); accumulatedAmount += item.getTradeAmount().getValue(); - tradePrices.add(tradePriceAsLong); + tradePrices[arrayIndex++] = tradePriceAsLong; } - Collections.sort(tradePrices); + Arrays.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(); + if (!set.isEmpty()) { + NavigableSet sortedSet = ImmutableSortedSet.copyOf(set); + open = sortedSet.first().getTradePrice().getValue(); + close = sortedSet.last().getTradePrice().getValue(); } long averagePrice; - Long[] prices = new Long[tradePrices.size()]; - tradePrices.toArray(prices); - long medianPrice = MathUtils.getMedian(prices); + long medianPrice = MathUtils.getMedian(tradePrices); boolean isBullish; if (CurrencyUtil.isCryptoCurrency(currencyCode)) { isBullish = close < open; @@ -278,9 +304,9 @@ public class ChartCalculations { 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() ? + Date dateFrom = new Date(getTimeFromTickIndex(tickIndex, itemsPerInterval)); + Date dateTo = new Date(getTimeFromTickIndex(tickIndex + 1, itemsPerInterval)); + String dateString = tickUnit.ordinal() > TickUnit.DAY.ordinal() ? DisplayUtils.formatDateTimeSpan(dateFrom, dateTo) : DisplayUtils.formatDate(dateFrom) + " - " + DisplayUtils.formatDate(dateTo); @@ -289,15 +315,29 @@ public class ChartCalculations { 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, + return new CandleData(tickIndex, 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) { + static long getTimeFromTickIndex(int tickIndex, List>> itemsPerInterval) { + if (tickIndex < 0 || tickIndex >= itemsPerInterval.size()) { return 0; } - return itemsPerInterval.get(tick).getKey().getTime(); + return itemsPerInterval.get(tickIndex).getKey().getTime(); + } + + private static class PriceAccumulator { + private long accumulatedAmount; + private long accumulatedVolume; + + void add(TradeStatistics3 tradeStatistics) { + accumulatedAmount += tradeStatistics.getAmount(); + accumulatedVolume += tradeStatistics.getTradeVolume().getValue(); + } + + long getAveragePrice() { + double accumulatedVolumeAsDouble = MathUtils.scaleUpByPowerOf10((double) accumulatedVolume, Coin.SMALLEST_UNIT_EXPONENT); + return MathUtils.roundDoubleToLong(accumulatedVolumeAsDouble / (double) accumulatedAmount); + } } } diff --git a/desktop/src/main/java/bisq/desktop/main/market/trades/TradeStatistics3ListItem.java b/desktop/src/main/java/bisq/desktop/main/market/trades/TradeStatistics3ListItem.java index db4fdb4762..0a8190df55 100644 --- a/desktop/src/main/java/bisq/desktop/main/market/trades/TradeStatistics3ListItem.java +++ b/desktop/src/main/java/bisq/desktop/main/market/trades/TradeStatistics3ListItem.java @@ -26,6 +26,9 @@ import bisq.core.util.FormattingUtils; import bisq.core.util.VolumeUtil; import bisq.core.util.coin.CoinFormatter; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.value.ObservableValue; + import lombok.experimental.Delegate; import org.jetbrains.annotations.Nullable; @@ -35,6 +38,7 @@ public class TradeStatistics3ListItem { private final TradeStatistics3 tradeStatistics3; private final CoinFormatter coinFormatter; private final boolean showAllTradeCurrencies; + private final ObservableValue observableWrapper; private String dateString; private String market; private String priceString; @@ -48,6 +52,11 @@ public class TradeStatistics3ListItem { this.tradeStatistics3 = tradeStatistics3; this.coinFormatter = coinFormatter; this.showAllTradeCurrencies = showAllTradeCurrencies; + observableWrapper = new ReadOnlyObjectWrapper<>(this).getReadOnlyProperty(); + } + + public ObservableValue asObservableValue() { + return observableWrapper; } public String getDateString() { 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 9323f2dbe8..eef101c0b9 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 @@ -53,6 +53,8 @@ import com.googlecode.jcsv.writer.CSVEntryConverter; import javax.inject.Inject; import javax.inject.Named; +import com.google.common.collect.Lists; + import com.jfoenix.controls.JFXTabPane; import javafx.stage.Stage; @@ -85,7 +87,6 @@ import org.fxmisc.easybind.EasyBind; import org.fxmisc.easybind.Subscription; import org.fxmisc.easybind.monadic.MonadicBinding; -import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.beans.value.ChangeListener; @@ -389,13 +390,15 @@ public class TradesChartsView extends ActivatableViewAndModel { - return model.tradeStatisticsByCurrency.stream() - .map(tradeStatistics -> new TradeStatistics3ListItem(tradeStatistics, - coinFormatter, - model.showAllTradeCurrenciesProperty.get())) - .collect(Collectors.toCollection(FXCollections::observableArrayList)); - }).whenComplete((listItems, throwable) -> { + boolean showAllTradeCurrencies = model.showAllTradeCurrenciesProperty.get(); + // Collect the list items in reverse chronological order, as this is the likely + // order 'sortedList' will place them in - this skips most of its (slow) sorting. + CompletableFuture.supplyAsync(() -> Lists.reverse(model.tradeStatisticsByCurrency).stream() + .map(tradeStatistics -> new TradeStatistics3ListItem(tradeStatistics, + coinFormatter, + showAllTradeCurrencies)) + .collect(Collectors.toCollection(FXCollections::observableArrayList)) + ).whenComplete((listItems, throwable) -> { log.debug("Creating listItems took {} ms", System.currentTimeMillis() - ts); long ts2 = System.currentTimeMillis(); @@ -640,7 +643,7 @@ public class TradesChartsView extends ActivatableViewAndModel() { @Override public String toString(Number object) { - long index = MathUtils.doubleToLong((double) object); + int index = object.intValue(); // The last tick is on the chart edge, it is not well spaced with // the previous tick and interferes with its label. if (MAX_TICKS + 1 == index) return ""; @@ -755,7 +758,7 @@ public class TradesChartsView extends ActivatableViewAndModel new ReadOnlyObjectWrapper<>(tradeStatistics.getValue())); + dateColumn.setCellValueFactory(tradeStatistics -> tradeStatistics.getValue().asObservableValue()); dateColumn.setCellFactory( new Callback<>() { @Override @@ -784,7 +787,7 @@ public class TradesChartsView extends ActivatableViewAndModel new ReadOnlyObjectWrapper<>(tradeStatistics.getValue())); + marketColumn.setCellValueFactory(tradeStatistics -> tradeStatistics.getValue().asObservableValue()); marketColumn.setCellFactory( new Callback<>() { @Override @@ -808,7 +811,7 @@ public class TradesChartsView extends ActivatableViewAndModel(); priceColumn.getStyleClass().add("number-column"); - priceColumn.setCellValueFactory((tradeStatistics) -> new ReadOnlyObjectWrapper<>(tradeStatistics.getValue())); + priceColumn.setCellValueFactory(tradeStatistics -> tradeStatistics.getValue().asObservableValue()); priceColumn.setCellFactory( new Callback<>() { @Override @@ -832,7 +835,7 @@ public class TradesChartsView extends ActivatableViewAndModel amountColumn = new AutoTooltipTableColumn<>(Res.get("shared.amountWithCur", Res.getBaseCurrencyCode())); amountColumn.getStyleClass().add("number-column"); - amountColumn.setCellValueFactory((tradeStatistics) -> new ReadOnlyObjectWrapper<>(tradeStatistics.getValue())); + amountColumn.setCellValueFactory(tradeStatistics -> tradeStatistics.getValue().asObservableValue()); amountColumn.setCellFactory( new Callback<>() { @Override @@ -857,7 +860,7 @@ public class TradesChartsView extends ActivatableViewAndModel(); volumeColumn.getStyleClass().add("number-column"); - volumeColumn.setCellValueFactory((tradeStatistics) -> new ReadOnlyObjectWrapper<>(tradeStatistics.getValue())); + volumeColumn.setCellValueFactory(tradeStatistics -> tradeStatistics.getValue().asObservableValue()); volumeColumn.setCellFactory( new Callback<>() { @Override @@ -881,7 +884,7 @@ public class TradesChartsView extends ActivatableViewAndModel paymentMethodColumn = new AutoTooltipTableColumn<>(Res.get("shared.paymentMethod")); paymentMethodColumn.getStyleClass().add("number-column"); - paymentMethodColumn.setCellValueFactory((tradeStatistics) -> new ReadOnlyObjectWrapper<>(tradeStatistics.getValue())); + paymentMethodColumn.setCellValueFactory(tradeStatistics -> tradeStatistics.getValue().asObservableValue()); paymentMethodColumn.setCellFactory( new Callback<>() { @Override 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 ff44daccb0..92f9ba2f9b 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 @@ -73,6 +73,7 @@ class TradesChartsViewModel extends ActivatableViewModel { // Enum /////////////////////////////////////////////////////////////////////////////////////////// + // NOTE: For the code to work correctly, order must be from biggest to smallest duration: public enum TickUnit { YEAR, MONTH, @@ -96,7 +97,7 @@ class TradesChartsViewModel extends ActivatableViewModel { final ObservableList> priceItems = FXCollections.observableArrayList(); final ObservableList> volumeItems = FXCollections.observableArrayList(); final ObservableList> volumeInUsdItems = FXCollections.observableArrayList(); - private final Map>> itemsPerInterval = new HashMap<>(); + private final List>> itemsPerInterval = new ArrayList<>(); TickUnit tickUnit; private int selectedTabIndex; @@ -208,7 +209,7 @@ class TradesChartsViewModel extends ActivatableViewModel { private void applyAsyncUsdAveragePriceMapsPerTickUnit(CompletableFuture completeFuture) { long ts = System.currentTimeMillis(); - ChartCalculations.getUsdAveragePriceMapsPerTickUnit(tradeStatisticsManager.getObservableTradeStatisticsSet()) + ChartCalculations.getUsdAveragePriceMapsPerTickUnit(tradeStatisticsManager.getNavigableTradeStatisticsSet()) .whenComplete((usdAveragePriceMapsPerTickUnit, throwable) -> { if (deactivateCalled) { return; @@ -236,8 +237,8 @@ class TradesChartsViewModel extends ActivatableViewModel { CompletableFuture future = new CompletableFuture<>(); long ts = System.currentTimeMillis(); ChartCalculations.getTradeStatisticsForCurrency(tradeStatisticsManager.getObservableTradeStatisticsSet(), - currencyCode, - showAllTradeCurrenciesProperty.get()) + currencyCode, + showAllTradeCurrenciesProperty.get()) .whenComplete((list, throwable) -> { if (deactivateCalled) { return; @@ -265,9 +266,9 @@ class TradesChartsViewModel extends ActivatableViewModel { private void applyAsyncChartData() { long ts = System.currentTimeMillis(); ChartCalculations.getUpdateChartResult(new ArrayList<>(tradeStatisticsByCurrency), - tickUnit, - usdAveragePriceMapsPerTickUnit, - getCurrencyCode()) + tickUnit, + usdAveragePriceMapsPerTickUnit, + getCurrencyCode()) .whenComplete((updateChartResult, throwable) -> { if (deactivateCalled) { return; @@ -278,7 +279,7 @@ class TradesChartsViewModel extends ActivatableViewModel { } UserThread.execute(() -> { itemsPerInterval.clear(); - itemsPerInterval.putAll(updateChartResult.getItemsPerInterval()); + itemsPerInterval.addAll(updateChartResult.getItemsPerInterval()); priceItems.setAll(updateChartResult.getPriceItems()); volumeItems.setAll(updateChartResult.getVolumeItems()); @@ -356,8 +357,8 @@ class TradesChartsViewModel extends ActivatableViewModel { return currencyListItems.getObservableList().stream().filter(e -> e.tradeCurrency.equals(selectedTradeCurrencyProperty.get())).findAny(); } - long getTimeFromTickIndex(long tick) { - return ChartCalculations.getTimeFromTickIndex(tick, itemsPerInterval); + long getTimeFromTickIndex(int tickIndex) { + return ChartCalculations.getTimeFromTickIndex(tickIndex, itemsPerInterval); } @@ -367,7 +368,7 @@ class TradesChartsViewModel extends ActivatableViewModel { private void fillTradeCurrencies() { // Don't use a set as we need all entries - List tradeCurrencyList = tradeStatisticsManager.getObservableTradeStatisticsSet().stream() + List tradeCurrencyList = tradeStatisticsManager.getNavigableTradeStatisticsSet().parallelStream() .flatMap(e -> CurrencyUtil.getTradeCurrency(e.getCurrency()).stream()) .collect(Collectors.toList()); currencyListItems.updateWithCurrencies(tradeCurrencyList, showAllCurrencyListItem); diff --git a/desktop/src/main/java/bisq/desktop/main/offer/bisq_v1/MutableOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/offer/bisq_v1/MutableOfferDataModel.java index 68ff744474..8cdb8fac49 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/bisq_v1/MutableOfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/bisq_v1/MutableOfferDataModel.java @@ -54,6 +54,7 @@ import bisq.core.util.coin.CoinUtil; import bisq.network.p2p.P2PService; import bisq.common.util.MathUtils; +import bisq.common.util.RangeUtils; import bisq.common.util.Tuple2; import bisq.common.util.Utilities; @@ -62,6 +63,8 @@ import org.bitcoinj.core.Transaction; import javax.inject.Named; +import com.google.common.collect.Range; + import javafx.beans.property.BooleanProperty; import javafx.beans.property.DoubleProperty; import javafx.beans.property.ObjectProperty; @@ -79,7 +82,6 @@ import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.collections.SetChangeListener; -import java.util.Comparator; import java.util.Date; import java.util.Objects; import java.util.Optional; @@ -362,14 +364,15 @@ public abstract class MutableOfferDataModel extends OfferDataModel implements Bs // Get average historic prices over for the prior trade period equaling the lock time var blocksRange = Restrictions.getLockTime(paymentAccount.getPaymentMethod().isBlockchain()); var startDate = new Date(System.currentTimeMillis() - blocksRange * 10L * 60000); - var sortedRangeData = tradeStatisticsManager.getObservableTradeStatisticsSet().stream() + var sortedRangeData = RangeUtils.subSet(tradeStatisticsManager.getNavigableTradeStatisticsSet()) + .withKey(TradeStatistics3::getDate) + .overRange(Range.atLeast(startDate)); + var sortedFilteredRangeData = sortedRangeData.stream() .filter(e -> e.getCurrency().equals(getTradeCurrency().getCode())) - .filter(e -> e.getDate().compareTo(startDate) >= 0) - .sorted(Comparator.comparing(TradeStatistics3::getDate)) .collect(Collectors.toList()); var movingAverage = new MathUtils.MovingAverage(10, 0.2); double[] extremes = {Double.MAX_VALUE, Double.MIN_VALUE}; - sortedRangeData.forEach(e -> { + sortedFilteredRangeData.forEach(e -> { var price = e.getTradePrice().getValue(); movingAverage.next(price).ifPresent(val -> { if (val < extremes[0]) extremes[0] = val; diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/closedtrades/ClosedTradesDataModel.java b/desktop/src/main/java/bisq/desktop/main/portfolio/closedtrades/ClosedTradesDataModel.java index eb06164bb0..1f9bb12c6b 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/closedtrades/ClosedTradesDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/closedtrades/ClosedTradesDataModel.java @@ -33,7 +33,6 @@ import bisq.core.trade.bsq_swap.BsqSwapTradeManager; import bisq.core.trade.model.Tradable; import bisq.core.user.Preferences; import bisq.core.util.PriceUtil; -import bisq.core.util.VolumeUtil; import org.bitcoinj.core.Coin; @@ -120,7 +119,7 @@ class ClosedTradesDataModel extends ActivatableDataModel { } Price price = PriceUtil.marketPriceToPrice(marketPrice); - return Optional.of(VolumeUtil.getVolume(amount, price)); + return Optional.of(price.getVolumeByAmount(amount)); } Volume getBsqVolumeInUsdWithAveragePrice(Coin amount) { 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 ae6a6ece51..89dad25477 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 @@ -21,6 +21,7 @@ import bisq.desktop.Navigation; import bisq.desktop.main.market.trades.charts.CandleData; import bisq.core.locale.FiatCurrency; +import bisq.core.locale.GlobalSettings; import bisq.core.monetary.Price; import bisq.core.offer.bisq_v1.OfferPayload; import bisq.core.payment.payload.PaymentMethod; @@ -37,17 +38,19 @@ import javafx.collections.ObservableSet; import javafx.util.Pair; +import java.time.LocalDateTime; + import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; -import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Date; import java.util.HashSet; -import java.util.Map; +import java.util.List; +import java.util.Locale; import java.util.Set; import org.junit.jupiter.api.BeforeEach; @@ -62,7 +65,6 @@ public class TradesChartsViewModelTest { TradeStatisticsManager tradeStatisticsManager; DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss"); - private File dir; OfferPayload offer = new OfferPayload(null, 0, null, @@ -105,14 +107,10 @@ public class TradesChartsViewModelTest { @BeforeEach public void setup() throws IOException { + GlobalSettings.setLocale(Locale.US); tradeStatisticsManager = mock(TradeStatisticsManager.class); model = new TradesChartsViewModel(tradeStatisticsManager, mock(Preferences.class), mock(PriceFeedService.class), mock(Navigation.class)); - dir = File.createTempFile("temp_tests1", ""); - //noinspection ResultOfMethodCallIgnored - dir.delete(); - //noinspection ResultOfMethodCallIgnored - dir.mkdir(); } @SuppressWarnings("ConstantConditions") @@ -130,9 +128,14 @@ public class TradesChartsViewModelTest { long amount = Coin.parseCoin("4").value; long volume = Fiat.parseFiat("EUR", "2200").value; boolean isBullish = true; + // NOTE: This is the LAST candle date (today) on the chart, which is presently formatted with + // both days the same, whereas earlier candle dates span two consecutive days (for TickUnit.DAY): + // TODO: Is this a bug? + String date = "May 7, 2023 - May 7, 2023"; Set set = new HashSet<>(); - final Date now = new Date(); + Date now = Date.from(LocalDateTime.of(2023, 5, 7, 12, 34) + .atZone(ChartCalculations.ZONE_ID).toInstant()); set.add(new TradeStatistics3(offer.getCurrencyCode(), Price.parse("EUR", "520").getValue(), @@ -171,9 +174,11 @@ public class TradesChartsViewModelTest { null, null)); - Map>> itemsPerInterval = null; - long tick = ChartCalculations.roundToTick(now, TradesChartsViewModel.TickUnit.DAY).getTime(); - CandleData candleData = ChartCalculations.getCandleData(tick, + Date tickStart = ChartCalculations.roundToTick(now, TradesChartsViewModel.TickUnit.DAY); + List>> itemsPerInterval = List.of( + new Pair<>(tickStart, set), new Pair<>(now, Set.of()) + ); + CandleData candleData = ChartCalculations.getCandleData(0, set, 0, TradesChartsViewModel.TickUnit.DAY, currencyCode, @@ -187,12 +192,13 @@ public class TradesChartsViewModelTest { assertEquals(amount, candleData.accumulatedAmount); assertEquals(volume, candleData.accumulatedVolume); assertEquals(isBullish, candleData.isBullish); + assertEquals(date, candleData.date); } // TODO JMOCKIT @Disabled @Test - public void testItemLists() throws ParseException { + public void testItemLists() { // Helper class to add historic trades class Trade { Trade(String date, String size, String price, String cc) { @@ -207,9 +213,9 @@ public class TradesChartsViewModelTest { } Date date; - String size; - String price; - String cc; + final String size; + final String price; + final String cc; } // Trade EUR diff --git a/desktop/src/test/java/bisq/desktop/main/offer/offerbook/OfferBookViewModelTest.java b/desktop/src/test/java/bisq/desktop/main/offer/offerbook/OfferBookViewModelTest.java index b8bf2b963c..28e98312fe 100644 --- a/desktop/src/test/java/bisq/desktop/main/offer/offerbook/OfferBookViewModelTest.java +++ b/desktop/src/test/java/bisq/desktop/main/offer/offerbook/OfferBookViewModelTest.java @@ -60,9 +60,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.util.Collections; import com.natpryce.makeiteasy.Maker; @@ -86,7 +84,6 @@ import static org.mockito.Mockito.when; public class OfferBookViewModelTest { private final CoinFormatter coinFormatter = new ImmutableCoinFormatter(Config.baseCurrencyNetworkParameters().getMonetaryFormat()); - private static final Logger log = LoggerFactory.getLogger(OfferBookViewModelTest.class); private User user; @BeforeEach @@ -101,7 +98,7 @@ public class OfferBookViewModelTest { private PriceUtil getPriceUtil() { PriceFeedService priceFeedService = mock(PriceFeedService.class); TradeStatisticsManager tradeStatisticsManager = mock(TradeStatisticsManager.class); - when(tradeStatisticsManager.getObservableTradeStatisticsSet()).thenReturn(FXCollections.observableSet()); + when(tradeStatisticsManager.getNavigableTradeStatisticsSet()).thenReturn(Collections.emptyNavigableSet()); return new PriceUtil(priceFeedService, tradeStatisticsManager, empty); } @@ -639,4 +636,3 @@ public class OfferBookViewModelTest { 1)); } } -