diff --git a/common/src/main/java/bisq/common/util/MathUtils.java b/common/src/main/java/bisq/common/util/MathUtils.java index cb3a32a9c71..6de3011a42e 100644 --- a/common/src/main/java/bisq/common/util/MathUtils.java +++ b/common/src/main/java/bisq/common/util/MathUtils.java @@ -22,6 +22,10 @@ import java.math.BigDecimal; import java.math.RoundingMode; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Optional; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -107,4 +111,60 @@ public static long getMedian(Long[] list) { } return median; } + + public static class MovingAverage { + Deque window; + private int size; + private long sum; + private double outlier; + + // Outlier as ratio + public MovingAverage(int size, double outlier) { + this.size = size; + window = new ArrayDeque<>(size); + this.outlier = outlier; + sum = 0; + } + + public Optional next(long val) { + var fullAtStart = isFull(); + if (fullAtStart) { + if (outlier > 0) { + // Return early if it's an outlier + var avg = (double) sum / size; + if (Math.abs(avg - val) / avg > outlier) { + return Optional.empty(); + } + } + sum -= window.remove(); + } + window.add(val); + sum += val; + if (!fullAtStart && isFull() && outlier != 0) { + removeInitialOutlier(); + } + // When discarding outliers, the first n non discarded elements return Optional.empty() + return outlier > 0 && !isFull() ? Optional.empty() : current(); + } + + boolean isFull() { + return window.size() == size; + } + + private void removeInitialOutlier() { + var element = window.iterator(); + while (element.hasNext()) { + var val = element.next(); + var avgExVal = (double) (sum - val) / (size - 1); + if (Math.abs(avgExVal - val) / avgExVal > outlier) { + element.remove(); + break; + } + } + } + + public Optional current() { + return window.size() == 0 ? Optional.empty() : Optional.of((double) sum / window.size()); + } + } } diff --git a/common/src/test/java/bisq/common/util/MathUtilsTest.java b/common/src/test/java/bisq/common/util/MathUtilsTest.java new file mode 100644 index 00000000000..1d2f56ce347 --- /dev/null +++ b/common/src/test/java/bisq/common/util/MathUtilsTest.java @@ -0,0 +1,57 @@ +/* + * 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 org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class MathUtilsTest { + + + @SuppressWarnings("OptionalGetWithoutIsPresent") + @Test + public void testMovingAverageWithoutOutlierExclusion() { + var values = new int[]{4, 5, 3, 1, 2, 4}; + // Moving average = 4, 4.5, 4, 3, 2, 7/3 + var movingAverage = new MathUtils.MovingAverage(3, 0); + int i = 0; + assertEquals(4, movingAverage.next(values[i++]).get(),0.001); + assertEquals(4.5, movingAverage.next(values[i++]).get(),0.001); + assertEquals(4, movingAverage.next(values[i++]).get(),0.001); + assertEquals(3, movingAverage.next(values[i++]).get(),0.001); + assertEquals(2, movingAverage.next(values[i++]).get(),0.001); + assertEquals((double) 7 / 3, movingAverage.next(values[i]).get(),0.001); + } + + @SuppressWarnings("OptionalGetWithoutIsPresent") + @Test + public void testMovingAverageWithOutlierExclusion() { + var values = new int[]{100, 102, 95, 101, 120, 115}; + // Moving average = N/A, N/A, 99, 99.333..., N/A, 103.666... + var movingAverage = new MathUtils.MovingAverage(3, 0.2); + int i = 0; + assertFalse(movingAverage.next(values[i++]).isPresent()); + assertFalse(movingAverage.next(values[i++]).isPresent()); + assertEquals(99, movingAverage.next(values[i++]).get(),0.001); + assertEquals(99.333, movingAverage.next(values[i++]).get(),0.001); + assertFalse(movingAverage.next(values[i++]).isPresent()); + assertEquals(103.666, movingAverage.next(values[i]).get(),0.001); + } +} diff --git a/core/src/main/java/bisq/core/btc/wallet/Restrictions.java b/core/src/main/java/bisq/core/btc/wallet/Restrictions.java index c74ff7519ce..8b0ff7f45bf 100644 --- a/core/src/main/java/bisq/core/btc/wallet/Restrictions.java +++ b/core/src/main/java/bisq/core/btc/wallet/Restrictions.java @@ -90,4 +90,9 @@ public static Coin getMinRefundAtMediatedDispute() { MIN_REFUND_AT_MEDIATED_DISPUTE = Coin.parseCoin("0.003"); // 0.003 BTC about 21 USD @ 7000 USD/BTC return MIN_REFUND_AT_MEDIATED_DISPUTE; } + + public static int getLockTime(boolean isAsset) { + // 10 days for altcoins, 20 days for other payment methods + return isAsset ? 144 * 10 : 144 * 20; + } } diff --git a/core/src/main/java/bisq/core/offer/CreateOfferService.java b/core/src/main/java/bisq/core/offer/CreateOfferService.java index e8f4b0cf811..739149c6142 100644 --- a/core/src/main/java/bisq/core/offer/CreateOfferService.java +++ b/core/src/main/java/bisq/core/offer/CreateOfferService.java @@ -182,7 +182,7 @@ public Offer createAndGetOffer(String offerId, List acceptedCountryCodes = PaymentAccountUtil.getAcceptedCountryCodes(paymentAccount); String bankId = PaymentAccountUtil.getBankId(paymentAccount); List acceptedBanks = PaymentAccountUtil.getAcceptedBanks(paymentAccount); - double sellerSecurityDeposit = getSellerSecurityDepositAsDouble(); + double sellerSecurityDeposit = getSellerSecurityDepositAsDouble(buyerSecurityDepositAsDouble); Coin txFeeFromFeeService = getEstimatedFeeAndTxSize(amount, direction, buyerSecurityDepositAsDouble, sellerSecurityDeposit).first; Coin txFeeToUse = txFee.isPositive() ? txFee : txFeeFromFeeService; Coin makerFeeAsCoin = getMakerFee(amount); @@ -287,8 +287,9 @@ public Coin getSecurityDeposit(OfferPayload.Direction direction, getSellerSecurityDeposit(amount, sellerSecurityDeposit); } - public double getSellerSecurityDepositAsDouble() { - return Restrictions.getSellerSecurityDepositAsPercent(); + public double getSellerSecurityDepositAsDouble(double buyerSecurityDeposit) { + return Preferences.USE_SYMMETRIC_SECURITY_DEPOSIT ? buyerSecurityDeposit : + Restrictions.getSellerSecurityDepositAsPercent(); } public Coin getMakerFee(Coin amount) { diff --git a/core/src/main/java/bisq/core/offer/OpenOfferManager.java b/core/src/main/java/bisq/core/offer/OpenOfferManager.java index cfc9bd1b3c7..5b74864790c 100644 --- a/core/src/main/java/bisq/core/offer/OpenOfferManager.java +++ b/core/src/main/java/bisq/core/offer/OpenOfferManager.java @@ -22,8 +22,8 @@ import bisq.core.btc.wallet.TradeWalletService; import bisq.core.dao.DaoFacade; import bisq.core.exceptions.TradePriceOutOfToleranceException; -import bisq.core.locale.Res; import bisq.core.filter.FilterManager; +import bisq.core.locale.Res; import bisq.core.offer.availability.DisputeAgentSelection; import bisq.core.offer.messages.OfferAvailabilityRequest; import bisq.core.offer.messages.OfferAvailabilityResponse; @@ -353,7 +353,7 @@ public void placeOffer(Offer offer, Coin reservedFundsForOffer = createOfferService.getReservedFundsForOffer(offer.getDirection(), offer.getAmount(), buyerSecurityDeposit, - createOfferService.getSellerSecurityDepositAsDouble()); + createOfferService.getSellerSecurityDepositAsDouble(buyerSecurityDeposit)); PlaceOfferModel model = new PlaceOfferModel(offer, reservedFundsForOffer, diff --git a/core/src/main/java/bisq/core/trade/protocol/tasks/maker/MakerSetsLockTime.java b/core/src/main/java/bisq/core/trade/protocol/tasks/maker/MakerSetsLockTime.java index 642d49c934b..94e817a340e 100644 --- a/core/src/main/java/bisq/core/trade/protocol/tasks/maker/MakerSetsLockTime.java +++ b/core/src/main/java/bisq/core/trade/protocol/tasks/maker/MakerSetsLockTime.java @@ -17,6 +17,7 @@ package bisq.core.trade.protocol.tasks.maker; +import bisq.core.btc.wallet.Restrictions; import bisq.core.trade.Trade; import bisq.core.trade.protocol.tasks.TradeTask; @@ -38,7 +39,7 @@ protected void run() { runInterceptHook(); // 10 days for altcoins, 20 days for other payment methods - int delay = processModel.getOffer().getPaymentMethod().isAsset() ? 144 * 10 : 144 * 20; + int delay = Restrictions.getLockTime(processModel.getOffer().getPaymentMethod().isAsset()); if (Config.baseCurrencyNetwork().isRegtest()) { delay = 5; } diff --git a/core/src/main/java/bisq/core/user/Preferences.java b/core/src/main/java/bisq/core/user/Preferences.java index 71c60125571..db2306f7735 100644 --- a/core/src/main/java/bisq/core/user/Preferences.java +++ b/core/src/main/java/bisq/core/user/Preferences.java @@ -120,6 +120,9 @@ public final class Preferences implements PersistedDataHost, BridgeAddressProvid new BlockChainExplorer("bsq.bisq.cc (@m52go)", "https://bsq.bisq.cc/tx.html?tx=", "https://bsq.bisq.cc/Address.html?addr=") )); + public static final boolean USE_SYMMETRIC_SECURITY_DEPOSIT = true; + + // payload is initialized so the default values are available for Property initialization. @Setter @Delegate(excludes = ExcludesDelegateMethods.class) diff --git a/core/src/main/java/bisq/core/util/FormattingUtils.java b/core/src/main/java/bisq/core/util/FormattingUtils.java index ac1512148f6..c567bf1cefe 100644 --- a/core/src/main/java/bisq/core/util/FormattingUtils.java +++ b/core/src/main/java/bisq/core/util/FormattingUtils.java @@ -211,6 +211,10 @@ public static String formatToPercentWithSymbol(double value) { return formatToPercent(value) + "%"; } + public static String formatToRoundedPercentWithSymbol(double value) { + return formatToPercent(value, new DecimalFormat("#")) + "%"; + } + public static String formatPercentagePrice(double value) { return formatToPercentWithSymbol(value); } @@ -219,6 +223,11 @@ public static String formatToPercent(double value) { DecimalFormat decimalFormat = new DecimalFormat("#.##"); decimalFormat.setMinimumFractionDigits(2); decimalFormat.setMaximumFractionDigits(2); + + return formatToPercent(value, decimalFormat); + } + + public static String formatToPercent(double value, DecimalFormat decimalFormat) { return decimalFormat.format(MathUtils.roundDouble(value * 100.0, 2)).replace(",", "."); } diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index 9e82bc86241..1b101cd767c 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -357,6 +357,8 @@ shared.notSigned.noNeed=This account type doesn't use signing offerbook.nrOffers=No. of offers: {0} offerbook.volume={0} (min - max) +offerbook.deposit=Deposit BTC (%) +offerbook.deposit.help=Deposit paid by each trader to guarantee the trade. Will be returned when the trade is completed. offerbook.createOfferToBuy=Create new offer to buy {0} offerbook.createOfferToSell=Create new offer to sell {0} @@ -480,6 +482,7 @@ createOffer.tac=With publishing this offer I agree to trade with any trader who createOffer.currencyForFee=Trade fee createOffer.setDeposit=Set buyer's security deposit (%) createOffer.setDepositAsBuyer=Set my security deposit as buyer (%) +createOffer.setDepositForBothTraders=Set both traders' security deposit (%) createOffer.securityDepositInfo=Your buyer''s security deposit will be {0} createOffer.securityDepositInfoAsBuyer=Your security deposit as buyer will be {0} createOffer.minSecurityDepositUsed=Min. buyer security deposit is used diff --git a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java index 01ba4db4027..49f9b5c6e7e 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferDataModel.java @@ -43,6 +43,8 @@ import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.PriceFeedService; import bisq.core.trade.handlers.TransactionResultHandler; +import bisq.core.trade.statistics.TradeStatistics2; +import bisq.core.trade.statistics.TradeStatisticsManager; import bisq.core.user.Preferences; import bisq.core.user.User; import bisq.core.util.FormattingUtils; @@ -79,8 +81,11 @@ import javafx.collections.ObservableList; import javafx.collections.SetChangeListener; +import java.util.Comparator; +import java.util.Date; import java.util.HashSet; import java.util.Optional; +import java.util.stream.Collectors; import javax.annotation.Nullable; @@ -126,6 +131,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel implements Bs private boolean marketPriceAvailable; private int feeTxSize = TxFeeEstimationService.TYPICAL_TX_WITH_1_INPUT_SIZE; protected boolean allowAmountUpdate = true; + private final TradeStatisticsManager tradeStatisticsManager; /////////////////////////////////////////////////////////////////////////////////////////// @@ -145,6 +151,7 @@ public MutableOfferDataModel(CreateOfferService createOfferService, FeeService feeService, @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, MakerFeeProvider makerFeeProvider, + TradeStatisticsManager tradeStatisticsManager, Navigation navigation) { super(btcWalletService); @@ -160,6 +167,7 @@ public MutableOfferDataModel(CreateOfferService createOfferService, this.btcFormatter = btcFormatter; this.makerFeeProvider = makerFeeProvider; this.navigation = navigation; + this.tradeStatisticsManager = tradeStatisticsManager; offerId = createOfferService.getRandomOfferId(); shortOfferId = Utilities.getShortId(offerId); @@ -257,6 +265,7 @@ public boolean initWithData(OfferPayload.Direction direction, TradeCurrency trad calculateVolume(); calculateTotalToPay(); updateBalance(); + setSuggestedSecurityDeposit(getPaymentAccount()); return true; } @@ -294,7 +303,7 @@ public void updateEstimatedFeeAndTxSize() { Tuple2 estimatedFeeAndTxSize = createOfferService.getEstimatedFeeAndTxSize(amount.get(), direction, buyerSecurityDeposit.get(), - createOfferService.getSellerSecurityDepositAsDouble()); + createOfferService.getSellerSecurityDepositAsDouble(buyerSecurityDeposit.get())); txFeeFromFeeService = estimatedFeeAndTxSize.first; feeTxSize = estimatedFeeAndTxSize.second; } @@ -317,14 +326,49 @@ void onPaymentAccountSelected(PaymentAccount paymentAccount) { this.paymentAccount = paymentAccount; setTradeCurrencyFromPaymentAccount(paymentAccount); - - buyerSecurityDeposit.set(preferences.getBuyerSecurityDepositAsPercent(getPaymentAccount())); + setSuggestedSecurityDeposit(getPaymentAccount()); if (amount.get() != null) this.amount.set(Coin.valueOf(Math.min(amount.get().value, getMaxTradeLimit()))); } } + private void setSuggestedSecurityDeposit(PaymentAccount paymentAccount) { + var minSecurityDeposit = preferences.getBuyerSecurityDepositAsPercent(getPaymentAccount()); + if (getTradeCurrency() == null) { + setBuyerSecurityDeposit(minSecurityDeposit, false); + return; + } + // Get average historic prices over for the prior trade period equaling the lock time + var blocksRange = Restrictions.getLockTime(paymentAccount.getPaymentMethod().isAsset()); + var startDate = new Date(System.currentTimeMillis() - blocksRange * 10 * 60000); + var sortedRangeData = tradeStatisticsManager.getObservableTradeStatisticsSet().stream() + .filter(e -> e.getCurrencyCode().equals(getTradeCurrency().getCode())) + .filter(e -> e.getTradeDate().compareTo(startDate) >= 0) + .sorted(Comparator.comparing(TradeStatistics2::getTradeDate)) + .collect(Collectors.toList()); + var movingAverage = new MathUtils.MovingAverage(10, 0.2); + double[] extremes = {Double.MAX_VALUE, Double.MIN_VALUE}; + sortedRangeData.forEach(e -> { + var price = e.getTradePrice().getValue(); + movingAverage.next(price).ifPresent(val -> { + if (val < extremes[0]) extremes[0] = val; + if (val > extremes[1]) extremes[1] = val; + }); + }); + var min = extremes[0]; + var max = extremes[1]; + if (min == 0d || max == 0d) { + setBuyerSecurityDeposit(minSecurityDeposit, false); + return; + } + // Suggested deposit is double the trade range over the previous lock time period, bounded by min/max deposit + var suggestedSecurityDeposit = + Math.min(2 * (max - min) / max, Restrictions.getMaxBuyerSecurityDepositAsPercent()); + buyerSecurityDeposit.set(Math.max(suggestedSecurityDeposit, minSecurityDeposit)); + } + + private void setTradeCurrencyFromPaymentAccount(PaymentAccount paymentAccount) { if (!paymentAccount.getTradeCurrencies().contains(tradeCurrency)) { if (paymentAccount.getSelectedTradeCurrency() != null) @@ -591,9 +635,12 @@ protected void setVolume(Volume volume) { this.volume.set(volume); } - void setBuyerSecurityDeposit(double value) { + void setBuyerSecurityDeposit(double value, boolean persist) { this.buyerSecurityDeposit.set(value); - preferences.setBuyerSecurityDepositAsPercent(value, getPaymentAccount()); + if (persist) { + // Only expected to persist for manually changed deposit values + preferences.setBuyerSecurityDepositAsPercent(value, getPaymentAccount()); + } } protected boolean isUseMarketBasedPriceValue() { @@ -650,7 +697,8 @@ private Coin getSellerSecurityDepositAsCoin() { if (amountAsCoin == null) amountAsCoin = Coin.ZERO; - Coin percentOfAmountAsCoin = CoinUtil.getPercentOfAmountAsCoin(createOfferService.getSellerSecurityDepositAsDouble(), amountAsCoin); + Coin percentOfAmountAsCoin = CoinUtil.getPercentOfAmountAsCoin( + createOfferService.getSellerSecurityDepositAsDouble(buyerSecurityDeposit.get()), amountAsCoin); return getBoundedSellerSecurityDepositAsCoin(percentOfAmountAsCoin); } diff --git a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferViewModel.java b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferViewModel.java index 3fb6731d5f8..55b9e75d5de 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/MutableOfferViewModel.java @@ -421,7 +421,7 @@ private void createListeners() { securityDepositStringListener = (ov, oldValue, newValue) -> { if (!ignoreSecurityDepositStringListener) { if (securityDepositValidator.validate(newValue).isValid) { - setBuyerSecurityDepositToModel(); + setBuyerSecurityDepositToModel(false); dataModel.calculateTotalToPay(); } updateButtonDisableState(); @@ -898,7 +898,7 @@ void onFocusOutBuyerSecurityDepositTextField(boolean oldValue, boolean newValue) .width(800) .actionButtonText(Res.get("createOffer.resetToDefault")) .onAction(() -> { - dataModel.setBuyerSecurityDeposit(defaultSecurityDeposit); + dataModel.setBuyerSecurityDeposit(defaultSecurityDeposit, false); ignoreSecurityDepositStringListener = true; buyerSecurityDeposit.set(FormattingUtils.formatToPercent(dataModel.getBuyerSecurityDeposit().get())); ignoreSecurityDepositStringListener = false; @@ -915,7 +915,7 @@ void onFocusOutBuyerSecurityDepositTextField(boolean oldValue, boolean newValue) } private void applyBuyerSecurityDepositOnFocusOut() { - setBuyerSecurityDepositToModel(); + setBuyerSecurityDepositToModel(true); ignoreSecurityDepositStringListener = true; buyerSecurityDeposit.set(FormattingUtils.formatToPercent(dataModel.getBuyerSecurityDeposit().get())); ignoreSecurityDepositStringListener = false; @@ -966,7 +966,8 @@ public String getTradeAmount() { } public String getSecurityDepositLabel() { - return dataModel.isBuyOffer() ? Res.get("createOffer.setDepositAsBuyer") : Res.get("createOffer.setDeposit"); + return Preferences.USE_SYMMETRIC_SECURITY_DEPOSIT ? Res.get("createOffer.setDepositForBothTraders") : + dataModel.isBuyOffer() ? Res.get("createOffer.setDepositAsBuyer") : Res.get("createOffer.setDeposit"); } public String getSecurityDepositPopOverLabel(String depositInBTC) { @@ -1145,11 +1146,13 @@ private void setVolumeToModel() { } } - private void setBuyerSecurityDepositToModel() { + private void setBuyerSecurityDepositToModel(boolean persistPreference) { if (buyerSecurityDeposit.get() != null && !buyerSecurityDeposit.get().isEmpty()) { - dataModel.setBuyerSecurityDeposit(ParsingUtils.parsePercentStringToDouble(buyerSecurityDeposit.get())); + dataModel.setBuyerSecurityDeposit(ParsingUtils.parsePercentStringToDouble(buyerSecurityDeposit.get()), + persistPreference); } else { - dataModel.setBuyerSecurityDeposit(Restrictions.getDefaultBuyerSecurityDepositAsPercent()); + dataModel.setBuyerSecurityDeposit(Restrictions.getDefaultBuyerSecurityDepositAsPercent(), + persistPreference); } } @@ -1157,7 +1160,7 @@ private void validateAndSetBuyerSecurityDepositToModel() { // If the security deposit in the model is not valid percent String value = FormattingUtils.formatToPercent(dataModel.getBuyerSecurityDeposit().get()); if (!securityDepositValidator.validate(value).isValid) { - dataModel.setBuyerSecurityDeposit(Restrictions.getDefaultBuyerSecurityDepositAsPercent()); + dataModel.setBuyerSecurityDeposit(Restrictions.getDefaultBuyerSecurityDepositAsPercent(), false); } } diff --git a/desktop/src/main/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModel.java index 05b1e859d69..c0f5e822f24 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModel.java @@ -32,6 +32,7 @@ import bisq.core.offer.OpenOfferManager; import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.PriceFeedService; +import bisq.core.trade.statistics.TradeStatisticsManager; import bisq.core.user.Preferences; import bisq.core.user.User; import bisq.core.util.FormattingUtils; @@ -63,6 +64,7 @@ public CreateOfferDataModel(CreateOfferService createOfferService, FeeService feeService, @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, MakerFeeProvider makerFeeProvider, + TradeStatisticsManager tradeStatisticsManager, Navigation navigation) { super(createOfferService, openOfferManager, @@ -76,6 +78,7 @@ public CreateOfferDataModel(CreateOfferService createOfferService, feeService, btcFormatter, makerFeeProvider, + tradeStatisticsManager, navigation); } } diff --git a/desktop/src/main/java/bisq/desktop/main/offer/offerbook/OfferBookView.java b/desktop/src/main/java/bisq/desktop/main/offer/offerbook/OfferBookView.java index f91c593dd0e..fa6d6663116 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/offerbook/OfferBookView.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/offerbook/OfferBookView.java @@ -38,7 +38,6 @@ import bisq.desktop.main.overlays.popups.Popup; import bisq.desktop.main.overlays.windows.OfferDetailsWindow; import bisq.desktop.util.CssTheme; -import bisq.desktop.util.DisplayUtils; import bisq.desktop.util.FormBuilder; import bisq.desktop.util.GUIUtil; import bisq.desktop.util.Layout; @@ -130,7 +129,7 @@ public class OfferBookView extends ActivatableViewAndModel paymentMethodComboBox; private AutoTooltipButton createOfferButton; private AutoTooltipTableColumn amountColumn, volumeColumn, marketColumn, - priceColumn, paymentMethodColumn, signingStateColumn, avatarColumn; + priceColumn, paymentMethodColumn, depositColumn, signingStateColumn, avatarColumn; private TableView tableView; private OfferView.OfferActionHandler offerActionHandler; @@ -224,6 +223,8 @@ public void initialize() { tableView.getColumns().add(volumeColumn); paymentMethodColumn = getPaymentMethodColumn(); tableView.getColumns().add(paymentMethodColumn); + depositColumn = getDepositColumn(); + tableView.getColumns().add(depositColumn); signingStateColumn = getSigningStateColumn(); tableView.getColumns().add(signingStateColumn); avatarColumn = getAvatarColumn(); @@ -245,6 +246,14 @@ public void initialize() { volumeColumn.setComparator(Comparator.comparing(o -> o.getOffer().getMinVolume(), Comparator.nullsFirst(Comparator.naturalOrder()))); paymentMethodColumn.setComparator(Comparator.comparing(o -> o.getOffer().getPaymentMethod())); avatarColumn.setComparator(Comparator.comparing(o -> o.getOffer().getOwnerNodeAddress().getFullAddress())); + depositColumn.setComparator(Comparator.comparing(o -> { + var isSellOffer = o.getOffer().getDirection() == OfferPayload.Direction.SELL; + var deposit = isSellOffer ? o.getOffer().getBuyerSecurityDeposit() : + o.getOffer().getSellerSecurityDeposit(); + + return (deposit == null) ? 0.0 : deposit.getValue() / (double) o.getOffer().getAmount().getValue(); + + }, Comparator.nullsFirst(Comparator.naturalOrder()))); nrOfOffersLabel = new AutoTooltipLabel(""); nrOfOffersLabel.setId("num-offers"); @@ -927,10 +936,56 @@ public void updateItem(final OfferBookListItem item, boolean empty) { return column; } + + private AutoTooltipTableColumn getDepositColumn() { + AutoTooltipTableColumn column = new AutoTooltipTableColumn<>( + Res.get("offerbook.deposit"), + Res.get("offerbook.deposit.help")) { + { + setMinWidth(70); + setSortable(true); + } + }; + + column.getStyleClass().add("number-column"); + column.setCellValueFactory((offer) -> new ReadOnlyObjectWrapper<>(offer.getValue())); + column.setCellFactory( + new Callback<>() { + @Override + public TableCell call( + TableColumn column) { + return new TableCell<>() { + @Override + public void updateItem(final OfferBookListItem item, boolean empty) { + super.updateItem(item, empty); + if (item != null && !empty) { + var isSellOffer = item.getOffer().getDirection() == OfferPayload.Direction.SELL; + var deposit = isSellOffer ? item.getOffer().getBuyerSecurityDeposit() : + item.getOffer().getSellerSecurityDeposit(); + if (deposit == null) { + setText(Res.get("shared.na")); + setGraphic(null); + } else { + setText(""); + setGraphic(new ColoredDecimalPlacesWithZerosText(model.formatDepositString( + deposit, item.getOffer().getAmount().getValue()), + GUIUtil.AMOUNT_DECIMALS_WITH_ZEROS)); + } + } else { + setText(""); + setGraphic(null); + } + } + }; + } + }); + return column; + } + private TableColumn getActionColumn() { TableColumn column = new AutoTooltipTableColumn<>(Res.get("shared.actions")) { { - setMinWidth(200); + setMinWidth(180); setSortable(false); } }; @@ -1147,8 +1202,8 @@ public void updateItem(final OfferBookListItem item, boolean empty) { private AutoTooltipTableColumn getAvatarColumn() { AutoTooltipTableColumn column = new AutoTooltipTableColumn<>(Res.get("offerbook.trader")) { { - setMinWidth(80); - setMaxWidth(80); + setMinWidth(60); + setMaxWidth(60); setSortable(true); } }; diff --git a/desktop/src/main/java/bisq/desktop/main/offer/offerbook/OfferBookViewModel.java b/desktop/src/main/java/bisq/desktop/main/offer/offerbook/OfferBookViewModel.java index 77203fb0dcd..64a6eaf9c02 100644 --- a/desktop/src/main/java/bisq/desktop/main/offer/offerbook/OfferBookViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/offer/offerbook/OfferBookViewModel.java @@ -47,8 +47,8 @@ import bisq.core.trade.closed.ClosedTradableManager; import bisq.core.user.Preferences; import bisq.core.user.User; -import bisq.core.util.coin.BsqFormatter; import bisq.core.util.FormattingUtils; +import bisq.core.util.coin.BsqFormatter; import bisq.core.util.coin.CoinFormatter; import bisq.network.p2p.NodeAddress; @@ -168,9 +168,7 @@ public OfferBookViewModel(User user, this.filteredItems = new FilteredList<>(offerBook.getOfferBookListItems()); this.sortedItems = new SortedList<>(filteredItems); - tradeCurrencyListChangeListener = c -> { - fillAllTradeCurrencies(); - }; + tradeCurrencyListChangeListener = c -> fillAllTradeCurrencies(); filterItemsListener = c -> { final Optional highestAmountOffer = filteredItems.stream() @@ -645,4 +643,9 @@ private static String getDirectionWithCodeDetailed(OfferPayload.Direction direct else return (direction == OfferPayload.Direction.SELL) ? Res.get("shared.buyingCurrency", currencyCode) : Res.get("shared.sellingCurrency", currencyCode); } + + public String formatDepositString(Coin deposit, long amount) { + var percentage = FormattingUtils.formatToRoundedPercentWithSymbol(deposit.getValue() / (double) amount); + return btcFormatter.formatCoin(deposit) + " (" + percentage + ")"; + } } diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModel.java b/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModel.java index c1bce03da5d..f3ba118a13e 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModel.java @@ -37,6 +37,7 @@ import bisq.core.proto.persistable.CorePersistenceProtoResolver; import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.PriceFeedService; +import bisq.core.trade.statistics.TradeStatisticsManager; import bisq.core.user.Preferences; import bisq.core.user.User; import bisq.core.util.FormattingUtils; @@ -74,6 +75,7 @@ class EditOfferDataModel extends MutableOfferDataModel { @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter btcFormatter, CorePersistenceProtoResolver corePersistenceProtoResolver, MakerFeeProvider makerFeeProvider, + TradeStatisticsManager tradeStatisticsManager, Navigation navigation) { super(createOfferService, openOfferManager, @@ -87,6 +89,7 @@ class EditOfferDataModel extends MutableOfferDataModel { feeService, btcFormatter, makerFeeProvider, + tradeStatisticsManager, navigation); this.corePersistenceProtoResolver = corePersistenceProtoResolver; } diff --git a/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModelTest.java b/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModelTest.java index 4d58655dcc9..e9550895894 100644 --- a/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModelTest.java +++ b/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferDataModelTest.java @@ -15,11 +15,14 @@ import bisq.core.payment.RevolutAccount; import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.PriceFeedService; +import bisq.core.trade.statistics.TradeStatisticsManager; import bisq.core.user.Preferences; import bisq.core.user.User; import org.bitcoinj.core.Coin; +import javafx.collections.FXCollections; + import java.util.HashSet; import java.util.UUID; @@ -52,17 +55,19 @@ public void setUp() { CreateOfferService createOfferService = mock(CreateOfferService.class); preferences = mock(Preferences.class); user = mock(User.class); + var tradeStats = mock(TradeStatisticsManager.class); when(btcWalletService.getOrCreateAddressEntry(anyString(), any())).thenReturn(addressEntry); when(preferences.isUsePercentageBasedPrice()).thenReturn(true); when(preferences.getBuyerSecurityDepositAsPercent(null)).thenReturn(0.01); when(createOfferService.getRandomOfferId()).thenReturn(UUID.randomUUID().toString()); + when(tradeStats.getObservableTradeStatisticsSet()).thenReturn(FXCollections.observableSet()); makerFeeProvider = mock(MakerFeeProvider.class); model = new CreateOfferDataModel(createOfferService, null, btcWalletService, null, preferences, user, null, priceFeedService, null, - feeService, null, makerFeeProvider, null); + feeService, null, makerFeeProvider, tradeStats, null); } @Test diff --git a/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferViewModelTest.java b/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferViewModelTest.java index 5ade9bdb794..17d8ab802e9 100644 --- a/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferViewModelTest.java +++ b/desktop/src/test/java/bisq/desktop/main/offer/createoffer/CreateOfferViewModelTest.java @@ -34,14 +34,16 @@ import bisq.core.offer.CreateOfferService; import bisq.core.offer.OfferPayload; import bisq.core.payment.PaymentAccount; +import bisq.core.payment.payload.PaymentMethod; import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.MarketPrice; import bisq.core.provider.price.PriceFeedService; +import bisq.core.trade.statistics.TradeStatisticsManager; import bisq.core.user.Preferences; import bisq.core.user.User; -import bisq.core.util.coin.ImmutableCoinFormatter; import bisq.core.util.coin.BsqFormatter; import bisq.core.util.coin.CoinFormatter; +import bisq.core.util.coin.ImmutableCoinFormatter; import bisq.core.util.validation.InputValidator; import bisq.common.config.Config; @@ -95,6 +97,7 @@ public void setUp() { SecurityDepositValidator securityDepositValidator = mock(SecurityDepositValidator.class); AccountAgeWitnessService accountAgeWitnessService = mock(AccountAgeWitnessService.class); CreateOfferService createOfferService = mock(CreateOfferService.class); + var tradeStats = mock(TradeStatisticsManager.class); when(btcWalletService.getOrCreateAddressEntry(anyString(), any())).thenReturn(addressEntry); when(btcWalletService.getBalanceForAddress(any())).thenReturn(Coin.valueOf(1000L)); @@ -102,6 +105,7 @@ public void setUp() { when(priceFeedService.getMarketPrice(anyString())).thenReturn(new MarketPrice("USD", 12684.0450, Instant.now().getEpochSecond(), true)); when(feeService.getTxFee(anyInt())).thenReturn(Coin.valueOf(1000L)); when(user.findFirstPaymentAccountWithCurrency(any())).thenReturn(paymentAccount); + when(paymentAccount.getPaymentMethod()).thenReturn(mock(PaymentMethod.class)); when(user.getPaymentAccountsAsObservable()).thenReturn(FXCollections.observableSet()); when(securityDepositValidator.validate(any())).thenReturn(new InputValidator.ValidationResult(false)); when(accountAgeWitnessService.getMyTradeLimit(any(), any(), any())).thenReturn(100000000L); @@ -109,11 +113,12 @@ public void setUp() { when(bsqFormatter.formatCoin(any())).thenReturn("0"); when(bsqWalletService.getAvailableConfirmedBalance()).thenReturn(Coin.ZERO); when(createOfferService.getRandomOfferId()).thenReturn(UUID.randomUUID().toString()); + when(tradeStats.getObservableTradeStatisticsSet()).thenReturn(FXCollections.observableSet()); CreateOfferDataModel dataModel = new CreateOfferDataModel(createOfferService, null, btcWalletService, bsqWalletService, empty, user, null, priceFeedService, accountAgeWitnessService, feeService, - coinFormatter, mock(MakerFeeProvider.class), null); + coinFormatter, mock(MakerFeeProvider.class), tradeStats, null); dataModel.initWithData(OfferPayload.Direction.BUY, new CryptoCurrency("BTC", "bitcoin")); dataModel.activate(); diff --git a/desktop/src/test/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModelTest.java b/desktop/src/test/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModelTest.java index b362adf18a1..9865f12fe14 100644 --- a/desktop/src/test/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModelTest.java +++ b/desktop/src/test/java/bisq/desktop/main/portfolio/editoffer/EditOfferDataModelTest.java @@ -19,6 +19,7 @@ import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.MarketPrice; import bisq.core.provider.price.PriceFeedService; +import bisq.core.trade.statistics.TradeStatisticsManager; import bisq.core.user.Preferences; import bisq.core.user.User; import bisq.core.util.coin.BsqFormatter; @@ -95,7 +96,7 @@ public void setUp() { btcWalletService, bsqWalletService, empty, user, null, priceFeedService, accountAgeWitnessService, feeService, null, null, - mock(MakerFeeProvider.class), null); + mock(MakerFeeProvider.class), mock(TradeStatisticsManager.class), null); } @Test diff --git a/p2p/src/main/java/bisq/network/p2p/network/NetworkNode.java b/p2p/src/main/java/bisq/network/p2p/network/NetworkNode.java index 58cd7a210b3..8e8e48b1686 100644 --- a/p2p/src/main/java/bisq/network/p2p/network/NetworkNode.java +++ b/p2p/src/main/java/bisq/network/p2p/network/NetworkNode.java @@ -345,7 +345,7 @@ public void shutDown(Runnable shutDownCompleteHandler) { allConnections.forEach(c -> c.shutDown(CloseConnectionReason.APP_SHUT_DOWN, () -> { shutdownCompleted.getAndIncrement(); - log.info("Shutdown o fnode {} completed", c.getPeersNodeAddressOptional()); + log.info("Shutdown of node {} completed", c.getPeersNodeAddressOptional()); if (shutdownCompleted.get() == numConnections) { log.info("Shutdown completed with all connections closed"); timeoutHandler.stop();