diff --git a/java-examples/src/main/java/bisq/bots/AbstractBot.java b/java-examples/src/main/java/bisq/bots/AbstractBot.java index f6ba7c9..48425f0 100644 --- a/java-examples/src/main/java/bisq/bots/AbstractBot.java +++ b/java-examples/src/main/java/bisq/bots/AbstractBot.java @@ -20,6 +20,7 @@ import bisq.bots.table.builder.TableBuilder; import bisq.proto.grpc.*; import bisq.proto.grpc.GetTradesRequest.Category; import io.grpc.StatusRuntimeException; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger; import protobuf.PaymentAccount; @@ -37,6 +38,7 @@ import static bisq.bots.BotUtils.*; import static bisq.bots.table.builder.TableType.BSQ_BALANCE_TBL; import static bisq.bots.table.builder.TableType.BTC_BALANCE_TBL; import static bisq.proto.grpc.GetOfferCategoryReply.OfferCategory.BSQ_SWAP; +import static bisq.proto.grpc.GetTradesRequest.Category.CLOSED; import static io.grpc.Status.*; import static java.lang.String.format; import static java.lang.System.exit; @@ -61,6 +63,7 @@ public abstract class AbstractBot { protected final String walletPassword; protected final String conf; protected final GrpcStubs grpcStubs; + @Getter protected final boolean isDryRun; // This is an experimental option for simulating and automating protocol payment steps during bot development. // Be extremely careful in its use; You do not want to "simulate" payments when API daemon is connected to mainnet. @@ -71,8 +74,8 @@ public abstract class AbstractBot { protected final List preferredTradingPeers = new ArrayList<>(); // Used during dry runs to track offers that would be taken. - // This list should remain empty if super.dryRun = FALSE until bot can take multiple offers in one session. - protected final List offersTaken = new ArrayList<>(); + // This list should stay empty when dryRun = false. + protected final List offersTakenDuringDryRun = new ArrayList<>(); protected final boolean canUseBash = getBashPath().isPresent(); protected boolean isShutdown = false; @@ -82,9 +85,20 @@ public abstract class AbstractBot { protected final Supplier minimumTxFeeRate = () -> txFeeRates.get().getMinFeeServiceRate(); protected final Supplier mostRecentTxFeeRate = () -> txFeeRates.get().getFeeServiceRate(); - // Constructor + /** + * Constructor that optionally prompts user to enter wallet password in the console. + *

+ * The wallet password prompt will be skipped if the given program args array already contains the + * '--wallet-password' option. This situation can occur when a bot calls another bot, and passes its + * wallet password option with validated value to this constructor. + *

+ * + * @param args program arguments + */ public AbstractBot(String[] args) { - this.args = toArgsWithWalletPassword.apply(args); + this.args = hasWalletPasswordOpt.test(args) + ? args + : toArgsWithWalletPassword.apply(args); Config bisqClientOpts = new Config(this.args, defaultPropertiesFilename.get()); this.walletPassword = bisqClientOpts.getWalletPassword(); this.conf = bisqClientOpts.getConf(); @@ -113,7 +127,11 @@ public abstract class AbstractBot { var reply = grpcStubs.versionService.getVersion(request); log.info("API daemon {} is available.", reply.getVersion()); } catch (StatusRuntimeException grpcException) { - log.error("Fatal Error: {}, daemon not available. Shutting down bot.", toCleanErrorMessage.apply(grpcException)); + log.error("Fatal Error: {}, daemon not available.", toCleanErrorMessage.apply(grpcException)); + if (exceptionHasStatus.test(grpcException, UNAUTHENTICATED)) { + log.error("Make sure your bot requests' '--password' opts match the API daemon's '--apiPassword' opt."); + } + log.error("Shutting down bot."); exit(1); } } @@ -192,10 +210,10 @@ public abstract class AbstractBot { } /** - * Return true if bot has taken the offer during this session -- for dry runs only. + * Return true if bot is in dryrun mode, and has taken the offer during this session. */ protected final Predicate isAlreadyTaken = (offer) -> - offersTaken.stream().anyMatch(o -> o.getId().equals(offer.getId())); + this.isDryRun() && offersTakenDuringDryRun.stream().anyMatch(o -> o.getId().equals(offer.getId())); /** * Print a table of BSQ balance information. @@ -396,23 +414,54 @@ public abstract class AbstractBot { * XMR payment account, else throws an IllegalStateException. (The bot does not yet support BSQ Swaps.) */ protected void validatePaymentAccount(PaymentAccount paymentAccount) { + verifyPaymentAccountCurrencyIsSupported(paymentAccount); + } + + /** + * Verifies (1) the given PaymentAccount has a selected trade currency, (2) is a fiat or XMR payment account, + * and (3) the payment account's primary (selected) currency code matches the given currency code, else throws + * an IllegalStateException. + */ + protected void validatePaymentAccount(PaymentAccount paymentAccount, String currencyCode) { + verifyPaymentAccountCurrencyIsSupported(paymentAccount); + var selectedCurrencyCode = paymentAccount.getSelectedTradeCurrency().getCode(); + if (!selectedCurrencyCode.equalsIgnoreCase(currencyCode)) + throw new IllegalStateException( + format("The bot's configured paymentAccountId %s%n" + + "is the id for '%s', which was set up to trade %s, not %s.", + paymentAccount.getId(), + paymentAccount.getAccountName(), + selectedCurrencyCode, + currencyCode)); + } + + /** + * Throw an IllegalStateException if (1) the given payment account has no selected trade currency, + * or (2) the payment account's selected trade currency is not supported by this bot, or (3) the + * payment account's selected trade currency is BSQ. (Let the API daemon handle the payment + * account used for BSQ swaps.) + * + * @param paymentAccount the payment account + */ + private void verifyPaymentAccountCurrencyIsSupported(PaymentAccount paymentAccount) { if (!paymentAccount.hasSelectedTradeCurrency()) throw new IllegalStateException( - format("PaymentAccount with ID '%s' and name '%s' has no selected currency definition.", + format("Payment Account with ID '%s' and name '%s' has no selected currency definition.", paymentAccount.getId(), paymentAccount.getAccountName())); var selectedCurrencyCode = paymentAccount.getSelectedTradeCurrency().getCode(); // Hacky way to find out if this is an altcoin payment method, but there is no BLOCK_CHAINS proto enum or msg. - boolean isBlockChainsPaymentMethod = paymentAccount.getPaymentMethod().getId().equals("BLOCK_CHAINS"); + var isBlockChainsPaymentMethod = paymentAccount.getPaymentMethod().getId().equals("BLOCK_CHAINS"); if (isBlockChainsPaymentMethod && !isXmr.test(selectedCurrencyCode)) throw new IllegalStateException( format("This bot only supports fiat and monero (XMR) trading, not the %s altcoin.", selectedCurrencyCode)); if (isBsq.test(selectedCurrencyCode)) - throw new IllegalStateException("This bot does not support BSQ Swaps."); + throw new IllegalStateException("This bot supports BSQ swaps, but not BSQ v1 protocol trades\n." + + "Let the API daemon handle the (default) payment account used for BSQ swaps."); } /** @@ -632,9 +681,9 @@ public abstract class AbstractBot { * Print information about offers taken during bot simulation. */ protected void printDryRunProgress() { - if (isDryRun && offersTaken.size() > 0) { - log.info("You have \"taken\" {} offer(s) during dry run:", offersTaken.size()); - printOffersSummary(offersTaken); + if (isDryRun && offersTakenDuringDryRun.size() > 0) { + log.info("You have \"taken\" {} offer(s) during dry run:", offersTakenDuringDryRun.size()); + printOffersSummary(offersTakenDuringDryRun); } } @@ -642,7 +691,7 @@ public abstract class AbstractBot { * Add offer to list of taken offers -- for dry runs only. */ protected void addToOffersTaken(OfferInfo offer) { - offersTaken.add(offer); + offersTakenDuringDryRun.add(offer); printOfferSummary(offer); log.info("Did not actually take that offer during this simulation."); } @@ -666,6 +715,38 @@ public abstract class AbstractBot { } } + /** + * Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0. + */ + protected void handleNonFatalException(NonFatalException nonFatalException, long pollingInterval) { + log.warn(nonFatalException.getMessage()); + if (nonFatalException.hasStallTime()) { + long stallTime = nonFatalException.getStallTime(); + log.warn("A minute must pass between the previous and the next takeoffer attempt." + + " Stalling for {} seconds before the next takeoffer attempt.", + toSeconds.apply(stallTime + pollingInterval)); + runCountdown(log, stallTime); + } else { + runCountdown(log, pollingInterval); + } + } + + /** + * Lock the wallet, stop the API daemon, and terminate the bot with a non-zero status (error). + */ + protected void shutdownAfterTakeOfferFailure(StatusRuntimeException fatalException) { + log.error("", fatalException); + shutdownAfterFatalError("Shutting down API daemon and bot after fatal takeoffer error."); + } + + /** + * Log the fatal BSQ swap exception, shut down the API daemon, and terminate the bot with a non-zero status (error). + */ + protected void handleFatalBsqSwapException(StatusRuntimeException fatalException) { + log.error("", fatalException); + shutdownAfterFatalError("Shutting down API daemon and bot after failing to execute BSQ swap."); + } + /** * Lock the wallet, stop the API daemon, and terminate the bot with a non-zero status (error). */ @@ -683,6 +764,88 @@ public abstract class AbstractBot { exit(1); } + /** + * Print the day's completed trades since midnight today, then: + *

+ * If numOffersTaken >= maxTakeOffers, and trade completion is being simulated on regtest, shut down the bot. + *

+ * If numOffersTaken >= maxTakeOffers, and mainnet trade completion must be delegated to the UI, shut down the + * API daemon and the bot. + *

+ * If numOffersTaken < maxTakeOffers, just log the number of offers taken so far during the bot run. + * (Don't shut down anything.) + * + * @param numOffersTaken the number of offers taken during bot run + * @param maxTakeOffers the max number of offers that can be taken during bot run + */ + protected void maybeShutdownAfterSuccessfulSwap(int numOffersTaken, int maxTakeOffers) { + log.info("Here are today's completed trades:"); + printTradesSummaryForToday(CLOSED); + + if (!isDryRun) { + try { + lockWallet(); + } catch (NonFatalException ex) { + log.warn(ex.getMessage()); + } + } + if (numOffersTaken >= maxTakeOffers) { + isShutdown = true; + log.info("Shutting down API bot after executing {} BSQ swaps.", numOffersTaken); + exit(0); + } else { + log.info("You have completed {} BSQ swap(s) during this bot session.", numOffersTaken); + } + } + + /** + * Print the day's completed trades since midnight today, then: + *

+ * If numOffersTaken >= maxTakeOffers, and trade completion is being simulated on regtest, shut down the bot. + *

+ * If numOffersTaken >= maxTakeOffers, and mainnet trade completion must be delegated to the UI, shut down the + * API daemon and the bot. + *

+ * If numOffersTaken < maxTakeOffers, just log the number of offers taken so far during the bot run. + * (Don't shut down anything.) + * + * @param numOffersTaken the number of offers taken during bot run + * @param maxTakeOffers the max number of offers that can be taken during bot run + */ + protected void maybeShutdownAfterSuccessfulTradeCreation(int numOffersTaken, int maxTakeOffers) { + log.info("Here are today's completed trades:"); + printTradesSummaryForToday(CLOSED); + + if (!isDryRun) { + // If the bot is not in dryrun mode, lock the wallet. If dryrun=true, leave the wallet unlocked until the + // timeout expires, so the user can look at data in the daemon with the CLI (a convenience in dev/test). + try { + lockWallet(); + } catch (NonFatalException ex) { + log.warn(ex.getMessage()); + } + } + if (numOffersTaken >= maxTakeOffers) { + isShutdown = true; + if (canSimulatePaymentSteps) { + log.info("Shutting down bot after {} successful simulated trades." + + " API daemon will not be shut down.", + numOffersTaken); + sleep(2_000); + } else { + log.info("Shutting down API daemon and bot after taking {} offers." + + " Complete the trade(s) with the desktop UI.", + numOffersTaken); + sleep(2_000); + log.info("Sending stop request to daemon."); + stopDaemon(); + } + exit(0); + } else { + log.info("You have taken {} offers during this bot session.", numOffersTaken); + } + } + /** * Returns Properties object for this bot. * diff --git a/java-examples/src/main/java/bisq/bots/BotUtils.java b/java-examples/src/main/java/bisq/bots/BotUtils.java index 3582fae..ed62585 100644 --- a/java-examples/src/main/java/bisq/bots/BotUtils.java +++ b/java-examples/src/main/java/bisq/bots/BotUtils.java @@ -152,6 +152,13 @@ public class BotUtils { (offer, targetPrice) -> offer.getUseMarketBasedPrice() && new BigDecimal(offer.getPrice()).compareTo(targetPrice) >= 0; + /** + * Return true if the margin price based offer's market price margin (%) >= minxMarketPriceMargin (%). + */ + public static final BiPredicate isMarginGEMinMarketPriceMargin = + (offer, minMarketPriceMargin) -> offer.getUseMarketBasedPrice() + && offer.getMarketPriceMarginPct() >= minMarketPriceMargin.doubleValue(); + /** * Return true if the margin price based offer's market price margin (%) <= maxMarketPriceMargin (%). */ @@ -197,10 +204,16 @@ public class BotUtils { return distanceFromMarketPrice.compareTo(minMarketPriceMargin) >= 0; } + /** + * Return String "above" if minMarketPriceMargin (%) >= 0.00, else "below". + */ + public static final Function aboveOrBelowMinMarketPriceMargin = (minMarketPriceMargin) -> + minMarketPriceMargin.compareTo(ZERO) >= 0 ? "above" : "below"; + /** * Return String "below" if maxMarketPriceMargin (%) <= 0.00, else "above". */ - public static final Function aboveOrBelowMarketPrice = (maxMarketPriceMargin) -> + public static final Function aboveOrBelowMaxMarketPriceMargin = (maxMarketPriceMargin) -> maxMarketPriceMargin.compareTo(ZERO) <= 0 ? "below" : "above"; /** @@ -269,6 +282,12 @@ public class BotUtils { return appendWalletPasswordOpt(args, unvalidatedWalletPassword); }; + /** + * Return true if the '--wallet-password' option label if found in the given program args array. + */ + public static final Predicate hasWalletPasswordOpt = (args) -> + Arrays.stream(args).anyMatch(a -> a.contains("--wallet-password")); + /** * Return a wallet password read from stdin. If read from a command terminal, input will not be echoed. * If run in a virtual terminal (IDE console), the input will be echoed. diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java index 57c9ed5..2a26c14 100644 --- a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java @@ -25,48 +25,44 @@ import protobuf.PaymentAccount; import java.math.BigDecimal; import java.util.*; import java.util.function.BiPredicate; -import java.util.function.Predicate; import static bisq.bots.BotUtils.*; -import static bisq.proto.grpc.GetTradesRequest.Category.CLOSED; import static java.lang.String.format; -import static java.lang.System.exit; import static java.math.RoundingMode.HALF_UP; import static protobuf.OfferDirection.SELL; /** - * Bot for swapping BSQ for BTC at an attractive (high) price. The bot sends BSQ for BTC. - *

- * I'm taking liberties with the classname by not naming it TakeBestPricedOfferToSellBtcForBsq. + * Bot for selling BSQ for BTC at an attractive (higher) price. The bot sends BSQ for BTC. */ @Slf4j @Getter public class TakeBestPricedOfferToBuyBsq extends AbstractBot { + // Taker bot's default BSQ payment account trading currency code. + private static final String CURRENCY_CODE = "BSQ"; + // Config file: resources/TakeBestPricedOfferToBuyBsq.properties. private final Properties configFile; // Taker bot's default BSQ Swap payment account. private final PaymentAccount paymentAccount; - // Taker bot's payment account trading currency code (BSQ). - private final String currencyCode; // Taker bot's minimum market price margin. A takeable BSQ Swap offer's fixed-price must be >= minMarketPriceMargin (%). // Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison. private final BigDecimal minMarketPriceMargin; // Hard coded 30-day average BSQ trade price, used for development over regtest (ignored when running on mainnet). private final BigDecimal regtest30DayAvgBsqPrice; - // Taker bot's min BTC amount to sell (we are buying BSQ). A takeable offer's amount must be >= minAmount BTC. + // Taker bot's minimum BTC amount to trade. A takeable offer's amount must be >= minAmount BTC. private final BigDecimal minAmount; - // Taker bot's max BTC amount to sell (we are buying BSQ). A takeable offer's amount must be <= maxAmount BTC. + // Taker bot's maximum BTC amount to trade. A takeable offer's amount must be <= maxAmount BTC. private final BigDecimal maxAmount; // Taker bot's max acceptable transaction fee rate. private final long maxTxFeeRate; - // Maximum # of offers to take during one bot session (shut down bot after N swaps). + // Maximum # of offers to take during one bot session (shut down bot after taking N swap offers). private final int maxTakeOffers; // Offer polling frequency must be > 1000 ms between each getoffers request. private final long pollingInterval; - // The # of BSQ swap offers taken during the bot session (since startup). + // The # of offers taken during the bot session (since startup). private int numOffersTaken = 0; public TakeBestPricedOfferToBuyBsq(String[] args) { @@ -74,7 +70,6 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available. this.configFile = loadConfigFile(); this.paymentAccount = getBsqSwapPaymentAccount(); - this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode(); this.minMarketPriceMargin = new BigDecimal(configFile.getProperty("minMarketPriceMargin")) .setScale(2, HALF_UP); this.regtest30DayAvgBsqPrice = new BigDecimal(configFile.getProperty("regtest30DayAvgBsqPrice")) @@ -105,7 +100,7 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { } // Get all available and takeable offers, sorted by price descending. - var offers = getOffers(SELL.name(), currencyCode).stream() + var offers = getOffers(SELL.name(), CURRENCY_CODE).stream() .filter(o -> !isAlreadyTaken.test(o)) .toList(); @@ -142,7 +137,7 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { if (isDryRun) { addToOffersTaken(offer); numOffersTaken++; - maybeShutdownAfterSuccessfulSwap(); + maybeShutdownAfterSuccessfulSwap(numOffersTaken, maxTakeOffers); } else { // An encrypted wallet must be unlocked before calling takeoffer and gettrade. // Unlock the wallet for 10 minutes. If the wallet is already unlocked, @@ -160,11 +155,11 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { printBSQBalances("BSQ Balances After Swap Execution"); numOffersTaken++; - maybeShutdownAfterSuccessfulSwap(); + maybeShutdownAfterSuccessfulSwap(numOffersTaken, maxTakeOffers); } catch (NonFatalException nonFatalException) { - handleNonFatalException(nonFatalException); + handleNonFatalException(nonFatalException, pollingInterval); } catch (StatusRuntimeException fatalException) { - handleFatalException(fatalException); + handleFatalBsqSwapException(fatalException); } } } @@ -179,7 +174,7 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { configsByLabel.put("My Payment Account:", ""); configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); - configsByLabel.put("\tCurrency Code:", currencyCode); + configsByLabel.put("\tCurrency Code:", CURRENCY_CODE); configsByLabel.put("Trading Rules:", ""); configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers); configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte"); @@ -200,70 +195,16 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { log.info(toTable.apply("Bot Configuration", configsByLabel)); } - /** - * Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0. - */ - private void handleNonFatalException(NonFatalException nonFatalException) { - log.warn(nonFatalException.getMessage()); - if (nonFatalException.hasStallTime()) { - long stallTime = nonFatalException.getStallTime(); - log.warn("A minute must pass between the previous and the next takeoffer attempt." - + " Stalling for {} seconds before the next takeoffer attempt.", - toSeconds.apply(stallTime + pollingInterval)); - runCountdown(log, stallTime); - } else { - runCountdown(log, pollingInterval); - } - } - - /** - * Log the fatal exception, and shut down daemon and bot. - */ - private void handleFatalException(StatusRuntimeException fatalException) { - log.error("", fatalException); - shutdownAfterFatalError("Shutting down API daemon and bot after failing to execute BSQ swap."); - } - - /** - * Lock the wallet, stop the API daemon, and terminate the bot. - */ - private void maybeShutdownAfterSuccessfulSwap() { - log.info("Here are today's completed trades:"); - printTradesSummaryForToday(CLOSED); - - if (!isDryRun) { - try { - lockWallet(); - } catch (NonFatalException ex) { - log.warn(ex.getMessage()); - } - } - if (numOffersTaken >= maxTakeOffers) { - isShutdown = true; - log.info("Shutting down API bot after executing {} BSQ swaps.", numOffersTaken); - exit(0); - } else { - log.info("You have completed {} BSQ swap(s) during this bot session.", numOffersTaken); - } - } - /** * Return true is fixed-price offer's price >= the bot's max market price margin. Allows bot to take a * fixed-priced offer if the price is >= {@link #minMarketPriceMargin} (%) of the current market price. */ protected final BiPredicate isFixedPriceGEMaxMarketPriceMargin = - (offer, currentMarketPrice) -> BotUtils.isFixedPriceGEMinMarketPriceMargin( + (offer, currentMarketPrice) -> isFixedPriceGEMinMarketPriceMargin( offer, currentMarketPrice, this.getMinMarketPriceMargin()); - /** - * Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries). - * TODO API's takeoffer needs to support taking offer's minAmount. - */ - protected final Predicate isWithinBTCAmountBounds = (offer) -> - BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount()); - public static void main(String[] args) { TakeBestPricedOfferToBuyBsq bot = new TakeBestPricedOfferToBuyBsq(args); bot.run(); @@ -297,28 +238,28 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) .filter(isMakerPreferredTradingPeer) .filter(o -> isFixedPriceGEMaxMarketPriceMargin.test(o, avgBsqPrice)) - .filter(isWithinBTCAmountBounds) + .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount())) .findFirst(); else return offers.stream() .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) .filter(o -> isFixedPriceGEMaxMarketPriceMargin.test(o, avgBsqPrice)) - .filter(isWithinBTCAmountBounds) + .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount())) .findFirst(); } void printCriteriaSummary() { if (isZero.test(minMarketPriceMargin)) { - log.info("Looking for offers to {}, with a fixed-price at or greater than" + log.info("Looking for offers to {}, with a fixed-price at or higher than" + " the 30-day average BSQ trade price of {} BTC.", MARKET_DESCRIPTION, avgBsqPrice); } else { - log.info("Looking for offers to {}, with a fixed-price at or greater than" + log.info("Looking for offers to {}, with a fixed-price at or higher than" + " {}% {} the 30-day average BSQ trade price of {} BTC.", MARKET_DESCRIPTION, minMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below". - aboveOrBelowMarketPrice.apply(minMarketPriceMargin), + aboveOrBelowMinMarketPriceMargin.apply(minMarketPriceMargin), avgBsqPrice); } } @@ -344,12 +285,12 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO" : "N/A"); var fixedPriceLabel = format("Is offer fixed-price (%s) >= bot's minimum price (%s)?", - offer.getPrice() + " " + currencyCode, - targetPrice + " " + currencyCode); + offer.getPrice() + " BTC", + targetPrice + " BTC"); filterResultsByLabel.put(fixedPriceLabel, isFixedPriceGEMaxMarketPriceMargin.test(offer, avgBsqPrice)); var btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount); filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?", - isWithinBTCAmountBounds.test(offer)); + isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount())); var title = format("Fixed price BSQ swap offer %s filter results:", offer.getId()); log.info(toTable.apply(title, filterResultsByLabel)); diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBtc.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBtc.java index c281b39..8d3110b 100644 --- a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBtc.java +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBtc.java @@ -25,18 +25,14 @@ import protobuf.PaymentAccount; import java.math.BigDecimal; import java.util.*; import java.util.function.BiPredicate; -import java.util.function.Predicate; -import java.util.function.Supplier; import static bisq.bots.BotUtils.*; import static java.lang.String.format; -import static java.lang.System.exit; import static java.math.RoundingMode.HALF_UP; import static protobuf.OfferDirection.BUY; -import static protobuf.OfferDirection.SELL; /** - * The TakeBestPricedOfferToBuyBtc bot waits for attractively priced BUY BTC offers to appear, takes the offers + * The TakeBestPricedOfferToBuyBtc bot waits for attractively priced BUY BTC for fiat offers to appear, takes the offers * (up to a maximum of configured {@link #maxTakeOffers}), then shuts down both the API daemon and itself (the bot), * to allow the user to start the desktop UI application and complete the trades. *

@@ -60,18 +56,7 @@ import static protobuf.OfferDirection.SELL; * the offer maker is a preferred trading peer, * and the offer's BTC amount is between 0.10 and 0.25 BTC, * and the current transaction mining fee rate is below 20 sats / byte. - * - *

- * Another possible use case for this bot is to buy BTC with XMR. (We might say "sell XMR for BTC", but we need to - * remember that all Bisq offers are for buying or selling BTC.) - *

- *      Take an offer to buy BTC with XMR at or above current market price if:
- *          the offer maker is a preferred trading peer,
- *          and the offer's BTC amount is between 0.50 and 1.00 BTC,
- *          and the current transaction mining fee rate is below 15 sats / byte.
- * 
- *

- *

+ *
  * Usage:  TakeBestPricedOfferToBuyBtc  --password=api-password --port=api-port \
  *                          [--conf=take-best-priced-offer-to-buy-btc.conf] \
  *                          [--dryrun=true|false]
@@ -90,21 +75,21 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
     private final String currencyCode;
     // Taker bot's min market price margin.  A takeable offer's price margin (%) must be >= minMarketPriceMargin (%).
     private final BigDecimal minMarketPriceMargin;
-    // Taker bot's min BTC amount to buy (or sell in case of XMR).  A takeable offer's amount must be >= minAmount BTC.
+    // Taker bot's min BTC amount to trade.  A takeable offer's amount must be >= minAmount BTC.
     private final BigDecimal minAmount;
-    // Taker bot's max BTC amount to buy (or sell in case of XMR).   A takeable offer's amount must be <= maxAmount BTC.
+    // Taker bot's max BTC amount to trade.  A takeable offer's amount must be <= maxAmount BTC.
     private final BigDecimal maxAmount;
     // Taker bot's max acceptable transaction fee rate.
     private final long maxTxFeeRate;
     // Taker bot's trading fee currency code (BSQ or BTC).
     private final String bisqTradeFeeCurrency;
-    // Maximum # of offers to take during one bot session (shut down bot after N swaps).
+    // Maximum # of offers to take during one bot session (shut down bot after taking N offers).
     private final int maxTakeOffers;
 
     // Offer polling frequency must be > 1000 ms between each getoffers request.
     private final long pollingInterval;
 
-    // The # of BSQ swap offers taken during the bot session (since startup).
+    // The # of offers taken during the bot session (since startup).
     private int numOffersTaken = 0;
 
     public TakeBestPricedOfferToBuyBtc(String[] args) {
@@ -144,12 +129,9 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
                 continue;
             }
 
-            // Taker bot's getOffers(direction) request param.  For fiat offers, is BUY (BTC), for XMR offers, is SELL (BTC).
-            String offerDirection = isXmr.test(currencyCode) ? SELL.name() : BUY.name();
-
-            // Get all available and takeable offers, sorted by price ascending.
+            // Get all available and takeable buy BTC for fiat offers, sorted by price descending.
             // The list contains both fixed-price and market price margin based offers.
-            var offers = getOffers(offerDirection, currencyCode).stream()
+            var offers = getOffers(BUY.name(), currencyCode).stream()
                     .filter(o -> !isAlreadyTaken.test(o))
                     .toList();
 
@@ -191,7 +173,7 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
         if (isDryRun) {
             addToOffersTaken(offer);
             numOffersTaken++;
-            maybeShutdownAfterSuccessfulTradeCreation();
+            maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers);
         } else {
             // An encrypted wallet must be unlocked before calling takeoffer and gettrade.
             // Unlock the wallet for 5 minutes.  If the wallet is already unlocked,
@@ -212,81 +194,15 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
                     printBTCBalances("BTC Balances After Simulated Trade Completion");
                 }
                 numOffersTaken++;
-                maybeShutdownAfterSuccessfulTradeCreation();
+                maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers);
             } catch (NonFatalException nonFatalException) {
-                handleNonFatalException(nonFatalException);
+                handleNonFatalException(nonFatalException, pollingInterval);
             } catch (StatusRuntimeException fatalException) {
-                handleFatalException(fatalException);
+                shutdownAfterTakeOfferFailure(fatalException);
             }
         }
     }
 
-    /**
-     * Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0.
-     */
-    private void handleNonFatalException(NonFatalException nonFatalException) {
-        log.warn(nonFatalException.getMessage());
-        if (nonFatalException.hasStallTime()) {
-            long stallTime = nonFatalException.getStallTime();
-            log.warn("A minute must pass between the previous and the next takeoffer attempt."
-                            + "  Stalling for {} seconds before the next takeoffer attempt.",
-                    toSeconds.apply(stallTime + pollingInterval));
-            runCountdown(log, stallTime);
-        } else {
-            runCountdown(log, pollingInterval);
-        }
-    }
-
-    /**
-     * Log the fatal exception, and shut down daemon and bot.
-     */
-    private void handleFatalException(StatusRuntimeException fatalException) {
-        log.error("", fatalException);
-        shutdownAfterFailedTradePreparation();
-    }
-
-    /**
-     * Lock the wallet, stop the API daemon, and terminate the bot.
-     */
-    private void maybeShutdownAfterSuccessfulTradeCreation() {
-        if (!isDryRun) {
-            try {
-                lockWallet();
-            } catch (NonFatalException ex) {
-                log.warn(ex.getMessage());
-            }
-        }
-        if (numOffersTaken >= maxTakeOffers) {
-            isShutdown = true;
-
-            if (canSimulatePaymentSteps) {
-                log.info("Shutting down bot after {} successful simulated trades."
-                                + "  API daemon will not be shut down.",
-                        numOffersTaken);
-                sleep(2_000);
-            } else {
-                log.info("Shutting down API daemon and bot after taking {} offers."
-                                + "  Complete the trade(s) with the desktop UI.",
-                        numOffersTaken);
-                sleep(2_000);
-                log.info("Sending stop request to daemon.");
-                stopDaemon();
-            }
-
-            exit(0);
-
-        } else {
-            log.info("You have taken {} offers during this bot session.", numOffersTaken);
-        }
-    }
-
-    /**
-     * Lock the wallet, stop the API daemon, and terminate the bot with a non-zero status (error).
-     */
-    private void shutdownAfterFailedTradePreparation() {
-        shutdownAfterFatalError("Shutting down API daemon and bot after failing to find new trade.");
-    }
-
     /**
      * Return true is fixed-price offer's price >= the bot's min market price margin.  Allows bot to take a
      * fixed-priced offer if the price is >= {@link #minMarketPriceMargin} (%) of the current market price.
@@ -297,13 +213,6 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
                     currentMarketPrice,
                     this.getMinMarketPriceMargin());
 
-    /**
-     * Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries).
-     *  TODO API's takeoffer needs to support taking offer's minAmount.
-     */
-    protected final Predicate isWithinBTCAmountBounds = (offer) ->
-            BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
-
     private void printBotConfiguration() {
         var configsByLabel = new LinkedHashMap();
         configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion());
@@ -338,17 +247,12 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
      * performs candidate offer filtering, and provides useful log statements.
      */
     private class TakeCriteria {
+        private static final String MARKET_DESCRIPTION = "Buy BTC";
+
         private final BigDecimal currentMarketPrice;
         @Getter
         private final BigDecimal targetPrice;
 
-        private final Supplier marketDescription = () -> {
-            if (isXmr.test(currencyCode))
-                return "Buy XMR (Sell BTC)";
-            else
-                return "Buy BTC";
-        };
-
         public TakeCriteria() {
             this.currentMarketPrice = getCurrentMarketPrice(currencyCode);
             this.targetPrice = calcTargetPrice(minMarketPriceMargin, currentMarketPrice, currencyCode);
@@ -367,39 +271,39 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
                         .filter(isMakerPreferredTradingPeer)
                         .filter(o -> isMarginBasedPriceGETargetPrice.test(o, targetPrice)
                                 || isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice))
-                        .filter(isWithinBTCAmountBounds)
+                        .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
                         .findFirst();
             else
                 return offers.stream()
                         .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
                         .filter(o -> isMarginBasedPriceGETargetPrice.test(o, targetPrice)
                                 || isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice))
-                        .filter(isWithinBTCAmountBounds)
+                        .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
                         .findFirst();
         }
 
         void printCriteriaSummary() {
             if (isZero.test(minMarketPriceMargin)) {
-                log.info("Looking for offers to {}, priced at or higher than the current market price {} {}.",
-                        marketDescription.get(),
+                log.info("Looking for offers to {}, priced at or higher than the current market price of {} {}.",
+                        MARKET_DESCRIPTION,
                         currentMarketPrice,
-                        isXmr.test(currencyCode) ? "BTC" : currencyCode);
+                        currencyCode);
             } else {
-                log.info("Looking for offers to {}, priced at or more than {}% {} the current market price {} {}.",
-                        marketDescription.get(),
+                log.info("Looking for offers to {}, priced at or higher than {}% {} the current market price of {} {}.",
+                        MARKET_DESCRIPTION,
                         minMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below".
-                        aboveOrBelowMarketPrice.apply(minMarketPriceMargin),
+                        aboveOrBelowMinMarketPriceMargin.apply(minMarketPriceMargin),
                         currentMarketPrice,
-                        isXmr.test(currencyCode) ? "BTC" : currencyCode);
+                        currencyCode);
             }
         }
 
         void printOffersAgainstCriteria(List offers) {
             log.info("Currently available {} offers -- want to take {} offer with price >= {} {}.",
-                    marketDescription.get(),
+                    MARKET_DESCRIPTION,
                     currencyCode,
                     targetPrice,
-                    isXmr.test(currencyCode) ? "BTC" : currencyCode);
+                    currencyCode);
             printOffersSummary(offers);
         }
 
@@ -416,23 +320,22 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
                     iHavePreferredTradingPeers.get()
                             ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO"
                             : "N/A");
-            var marginPriceLabel = format("Is offer's margin based price (%s) >= bot's target price (%s)?",
-                    offer.getUseMarketBasedPrice() ? offer.getPrice() : "N/A",
-                    offer.getUseMarketBasedPrice() ? targetPrice : "N/A");
-            filterResultsByLabel.put(marginPriceLabel,
-                    offer.getUseMarketBasedPrice()
-                            ? isMarginBasedPriceGETargetPrice.test(offer, targetPrice)
-                            : "N/A");
-            var fixedPriceLabel = format("Is offer's fixed-price (%s) >= bot's target price (%s)?",
-                    offer.getUseMarketBasedPrice() ? "N/A" : offer.getPrice() + " " + currencyCode,
-                    offer.getUseMarketBasedPrice() ? "N/A" : targetPrice + " " + currencyCode);
-            filterResultsByLabel.put(fixedPriceLabel,
-                    offer.getUseMarketBasedPrice()
-                            ? "N/A"
-                            : isFixedPriceGEMinMarketPriceMargin.test(offer, currentMarketPrice));
+
+            if (offer.getUseMarketBasedPrice()) {
+                var marginPriceLabel = format("Is offer's margin based price (%s) >= bot's target price (%s)?",
+                        offer.getPrice() + " " + currencyCode,
+                        targetPrice + " " + currencyCode);
+                filterResultsByLabel.put(marginPriceLabel, isMarginBasedPriceGETargetPrice.test(offer, targetPrice));
+            } else {
+                var fixedPriceLabel = format("Is offer's fixed-price (%s) >= bot's target price (%s)?",
+                        offer.getPrice() + " " + currencyCode,
+                        targetPrice + " " + currencyCode);
+                filterResultsByLabel.put(fixedPriceLabel, isFixedPriceGEMinMarketPriceMargin.test(offer, currentMarketPrice));
+            }
+
             String btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount);
             filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?",
-                    isWithinBTCAmountBounds.test(offer));
+                    isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount()));
 
             var title = format("%s offer %s filter results:",
                     offer.getUseMarketBasedPrice() ? "Margin based" : "Fixed price",
diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyXmr.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyXmr.java
new file mode 100644
index 0000000..1d5ea49
--- /dev/null
+++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyXmr.java
@@ -0,0 +1,330 @@
+/*
+ * 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.bots;
+
+import bisq.proto.grpc.OfferInfo;
+import io.grpc.StatusRuntimeException;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import protobuf.PaymentAccount;
+
+import java.math.BigDecimal;
+import java.util.*;
+import java.util.function.BiPredicate;
+
+import static bisq.bots.BotUtils.*;
+import static java.lang.String.format;
+import static java.math.RoundingMode.HALF_UP;
+import static protobuf.OfferDirection.SELL;
+
+/**
+ * The use case for the TakeBestPricedOfferToBuyXmr bot is to sell XMR for BTC at a high BTC price, e.g.:
+ * 
+ *      Take an offer to buy XMR from you for BTC, at no less than #.##% above or below current market price if:
+ *          the offer maker is a preferred trading peer,
+ *          and the offer's BTC amount is between 0.50 and 1.00 BTC,
+ *          and the current transaction mining fee rate is below 15 sats / byte.
+ *
+ * Usage:  TakeBestPricedOfferToBuyXmr  --password=api-password --port=api-port \
+ *                          [--conf=take-best-priced-offer-to-buy-xmr.conf] \
+ *                          [--dryrun=true|false]
+ *                          [--simulate-regtest-payment=true|false]
+ * 
+ *

+ * The criteria for determining which offers to take are defined in the bot's configuration file + * TakeBestPricedOfferToBuyXmr.properties (located in project's src/main/resources directory). The individual + * configurations are commented in the existing TakeBestPricedOfferToBuyXmr.properties, which should be used as a + * template for your own use case. + */ +@Slf4j +@Getter +public class TakeBestPricedOfferToBuyXmr extends AbstractBot { + + // Taker bot's XMR payment account trading currency code. + private static final String CURRENCY_CODE = "XMR"; + + // Config file: resources/TakeBestPricedOfferToBuyXmr.properties. + private final Properties configFile; + // Taker bot's XMR payment account (if the configured paymentAccountId is valid). + private final PaymentAccount paymentAccount; + // Taker bot's minimum market price margin. A takeable offer's price margin (%) must be >= minMarketPriceMargin (%). + private final BigDecimal minMarketPriceMargin; + // Taker bot's min BTC amount to trade. A takeable offer's amount must be >= minAmount BTC. + private final BigDecimal minAmount; + // Taker bot's max BTC amount to trade. A takeable offer's amount must be <= maxAmount BTC. + private final BigDecimal maxAmount; + // Taker bot's max acceptable transaction fee rate. + private final long maxTxFeeRate; + // Taker bot's trading fee currency code (BSQ or BTC). + private final String bisqTradeFeeCurrency; + // Maximum # of offers to take during one bot session (shut down bot after taking N offers). + private final int maxTakeOffers; + + // Offer polling frequency must be > 1000 ms between each getoffers request. + private final long pollingInterval; + + // The # of offers taken during the bot session (since startup). + private int numOffersTaken = 0; + + public TakeBestPricedOfferToBuyXmr(String[] args) { + super(args); + pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available. + this.configFile = loadConfigFile(); + this.paymentAccount = getPaymentAccount(configFile.getProperty("paymentAccountId")); + this.minMarketPriceMargin = new BigDecimal(configFile.getProperty("minMarketPriceMargin")) + .setScale(2, HALF_UP); + this.minAmount = new BigDecimal(configFile.getProperty("minAmount")); + this.maxAmount = new BigDecimal(configFile.getProperty("maxAmount")); + this.maxTxFeeRate = Long.parseLong(configFile.getProperty("maxTxFeeRate")); + this.bisqTradeFeeCurrency = configFile.getProperty("bisqTradeFeeCurrency"); + this.maxTakeOffers = Integer.parseInt(configFile.getProperty("maxTakeOffers")); + loadPreferredOnionAddresses.accept(configFile, preferredTradingPeers); + this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval")); + } + + /** + * Checks for the most attractive offer to take every {@link #pollingInterval} ms. After {@link #maxTakeOffers} + * are taken, bot will stop the API daemon, then shut itself down, prompting the user to start the desktop UI + * to complete the trade. + */ + @Override + public void run() { + var startTime = new Date().getTime(); + validateWalletPassword(walletPassword); + validatePollingInterval(pollingInterval); + validateTradeFeeCurrencyCode(bisqTradeFeeCurrency); + validatePaymentAccount(paymentAccount, CURRENCY_CODE); + printBotConfiguration(); + + while (!isShutdown) { + if (!isBisqNetworkTxFeeRateLowEnough.test(maxTxFeeRate)) { + runCountdown(log, pollingInterval); + continue; + } + + // Get all available and takeable sell BTC for XMR offers, sorted by price descending. + // The list may contain both fixed-price and market price margin based offers. + var offers = getOffers(SELL.name(), CURRENCY_CODE).stream() + .filter(o -> !isAlreadyTaken.test(o)) + .toList(); + + if (offers.isEmpty()) { + log.info("No takeable offers found."); + runCountdown(log, pollingInterval); + continue; + } + + // Define criteria for taking an offer, based on conf file. + TakeCriteria takeCriteria = new TakeCriteria(); + takeCriteria.printCriteriaSummary(); + takeCriteria.printOffersAgainstCriteria(offers); + + // Find takeable offer based on criteria. + Optional selectedOffer = takeCriteria.findTakeableOffer(offers); + // Try to take the offer, if found, or say 'no offer found' before going to sleep. + selectedOffer.ifPresentOrElse(offer -> takeOffer(takeCriteria, offer), + () -> { + var highestPricedOffer = offers.get(0); + log.info("No acceptable offer found. Closest possible candidate did not pass filters:"); + takeCriteria.printOfferAgainstCriteria(highestPricedOffer); + }); + + printDryRunProgress(); + runCountdown(log, pollingInterval); + pingDaemon(startTime); + } + } + + /** + * Attempt to take the available offer according to configured criteria. If successful, will block until a new + * trade is fully initialized with a trade contract. Otherwise, handles a non-fatal error and allows the bot to + * stay alive, or shuts down the bot upon fatal error. + */ + private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { + log.info("Will attempt to take offer '{}'.", offer.getId()); + takeCriteria.printOfferAgainstCriteria(offer); + if (isDryRun) { + addToOffersTaken(offer); + numOffersTaken++; + maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers); + } else { + // An encrypted wallet must be unlocked before calling takeoffer and gettrade. + // Unlock the wallet for 5 minutes. If the wallet is already unlocked, + // this command will override the timeout of the previous unlock command. + try { + unlockWallet(walletPassword, 600); + printBTCBalances("BTC Balances Before Take Offer Attempt"); + // Blocks until new trade is prepared, or times out. + takeV1ProtocolOffer(offer, paymentAccount, bisqTradeFeeCurrency, pollingInterval); + printBTCBalances("BTC Balances After Take Offer Attempt"); + + if (canSimulatePaymentSteps) { + var newTrade = getTrade(offer.getId()); + RegtestTradePaymentSimulator tradePaymentSimulator = new RegtestTradePaymentSimulator(args, + newTrade.getTradeId(), + paymentAccount); + tradePaymentSimulator.run(); + printBTCBalances("BTC Balances After Simulated Trade Completion"); + } + numOffersTaken++; + maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers); + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException, pollingInterval); + } catch (StatusRuntimeException fatalException) { + shutdownAfterTakeOfferFailure(fatalException); + } + } + } + + /** + * Return true is fixed-price offer's price >= the bot's min market price margin. Allows bot to take a + * fixed-priced offer if the price is >= {@link #minMarketPriceMargin} (%) of the current market price. + */ + protected final BiPredicate isFixedPriceGEMinMarketPriceMargin = + (offer, currentMarketPrice) -> BotUtils.isFixedPriceGEMinMarketPriceMargin( + offer, + currentMarketPrice, + this.getMinMarketPriceMargin()); + + private void printBotConfiguration() { + var configsByLabel = new LinkedHashMap(); + configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion()); + var network = getNetwork(); + configsByLabel.put("BTC Network:", network); + configsByLabel.put("My Payment Account:", ""); + configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); + configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); + configsByLabel.put("\tCurrency Code:", CURRENCY_CODE); + configsByLabel.put("Trading Rules:", ""); + configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers); + configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte"); + configsByLabel.put("\tMin Market Price Margin:", minMarketPriceMargin + "%"); + configsByLabel.put("\tMin BTC Amount:", minAmount + " BTC"); + configsByLabel.put("\tMax BTC Amount: ", maxAmount + " BTC"); + if (iHavePreferredTradingPeers.get()) { + configsByLabel.put("\tPreferred Trading Peers:", preferredTradingPeers.toString()); + } else { + configsByLabel.put("\tPreferred Trading Peers:", "N/A"); + } + configsByLabel.put("Bot Polling Interval:", pollingInterval + " ms"); + log.info(toTable.apply("Bot Configuration", configsByLabel)); + } + + public static void main(String[] args) { + TakeBestPricedOfferToBuyXmr bot = new TakeBestPricedOfferToBuyXmr(args); + bot.run(); + } + + /** + * Calculates additional takeoffer criteria based on conf file values, + * performs candidate offer filtering, and provides useful log statements. + */ + private class TakeCriteria { + private static final String MARKET_DESCRIPTION = "Buy XMR (Sell BTC)"; + + private final BigDecimal currentMarketPrice; + @Getter + private final BigDecimal targetPrice; + + public TakeCriteria() { + this.currentMarketPrice = getCurrentMarketPrice(CURRENCY_CODE); + this.targetPrice = calcTargetPrice(minMarketPriceMargin, currentMarketPrice, CURRENCY_CODE); + } + + /** + * Returns the highest priced offer passing the filters, or Optional.empty() if not found. + * The max tx fee rate filtering should have passed prior to calling this method. + * + * @param offers to filter + */ + Optional findTakeableOffer(List offers) { + if (iHavePreferredTradingPeers.get()) + return offers.stream() + .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) + .filter(isMakerPreferredTradingPeer) + .filter(o -> isMarginGEMinMarketPriceMargin.test(o, minMarketPriceMargin) + || isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice)) + .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount())) + .findFirst(); + else + return offers.stream() + .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) + .filter(o -> isMarginGEMinMarketPriceMargin.test(o, minMarketPriceMargin) + || isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice)) + .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount())) + .findFirst(); + } + + void printCriteriaSummary() { + if (isZero.test(minMarketPriceMargin)) { + log.info("Looking for offers to {}, priced at or higher than the current market price of {} BTC.", + MARKET_DESCRIPTION, + currentMarketPrice); + } else { + log.info("Looking for offers to {}, priced at or higher than {}% {} the current market price of {} BTC.", + MARKET_DESCRIPTION, + minMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below". + aboveOrBelowMinMarketPriceMargin.apply(minMarketPriceMargin), + currentMarketPrice); + } + } + + void printOffersAgainstCriteria(List offers) { + log.info("Currently available {} offers -- want to take {} offer with price >= {} BTC.", + MARKET_DESCRIPTION, + CURRENCY_CODE, + targetPrice); + printOffersSummary(offers); + } + + void printOfferAgainstCriteria(OfferInfo offer) { + printOfferSummary(offer); + + var filterResultsByLabel = new LinkedHashMap(); + filterResultsByLabel.put("Current Market Price:", currentMarketPrice + " BTC"); + filterResultsByLabel.put("Target Price (Min):", targetPrice + " BTC"); + filterResultsByLabel.put("Offer Price:", offer.getPrice() + " BTC"); + filterResultsByLabel.put("Offer maker used same payment method?", + usesSamePaymentMethod.test(offer, getPaymentAccount())); + filterResultsByLabel.put("Is offer maker a preferred trading peer?", + iHavePreferredTradingPeers.get() + ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO" + : "N/A"); + + if (offer.getUseMarketBasedPrice()) { + var marginPriceLabel = format("Is offer's price margin (%s%%) >= bot's min market price margin (%s%%)?", + offer.getMarketPriceMarginPct(), + minMarketPriceMargin); + filterResultsByLabel.put(marginPriceLabel, isMarginLEMaxMarketPriceMargin.test(offer, minMarketPriceMargin)); + } else { + var fixedPriceLabel = format("Is offer's fixed-price (%s) >= bot's target price (%s)?", + offer.getPrice() + " BTC", + targetPrice + " BTC"); + filterResultsByLabel.put(fixedPriceLabel, isFixedPriceGEMinMarketPriceMargin.test(offer, currentMarketPrice)); + } + + String btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount); + filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?", + isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount())); + + var title = format("%s offer %s filter results:", + offer.getUseMarketBasedPrice() ? "Margin based" : "Fixed price", + offer.getId()); + log.info(toTable.apply(title, filterResultsByLabel)); + } + } +} diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBsq.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBsq.java index b3f809e..5622119 100644 --- a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBsq.java +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBsq.java @@ -25,48 +25,44 @@ import protobuf.PaymentAccount; import java.math.BigDecimal; import java.util.*; import java.util.function.BiPredicate; -import java.util.function.Predicate; import static bisq.bots.BotUtils.*; -import static bisq.proto.grpc.GetTradesRequest.Category.CLOSED; import static java.lang.String.format; -import static java.lang.System.exit; import static java.math.RoundingMode.HALF_UP; import static protobuf.OfferDirection.BUY; /** - * Bot for swapping BTC for BSQ at an attractive (low) price. The bot receives BSQ for BTC. - *

- * I'm taking liberties with the classname by not naming it TakeBestPricedOfferToBuyBtcForBsq. + * Bot for buying BSQ with BTC at an attractive (lower) price. The bot receives BSQ for BTC. */ @Slf4j @Getter public class TakeBestPricedOfferToSellBsq extends AbstractBot { + // Taker bot's default BSQ payment account trading currency code. + private static final String CURRENCY_CODE = "BSQ"; + // Config file: resources/TakeBestPricedOfferToSellBsq.properties. private final Properties configFile; // Taker bot's default BSQ Swap payment account. private final PaymentAccount paymentAccount; - // Taker bot's payment account trading currency code (BSQ). - private final String currencyCode; // Taker bot's max market price margin. A takeable BSQ Swap offer's fixed-price must be <= maxMarketPriceMargin (%). // Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison. private final BigDecimal maxMarketPriceMargin; // Hard coded 30-day average BSQ trade price, used for development over regtest (ignored when running on mainnet). private final BigDecimal regtest30DayAvgBsqPrice; - // Taker bot's min BTC amount to sell (we are buying BSQ). A takeable offer's amount must be >= minAmount BTC. + // Taker bot's minimum BTC amount to trade. A takeable offer's amount must be >= minAmount BTC. private final BigDecimal minAmount; - // Taker bot's max BTC amount to sell (we are buying BSQ). A takeable offer's amount must be <= maxAmount BTC. + // Taker bot's maximum BTC amount to trade. A takeable offer's amount must be <= maxAmount BTC. private final BigDecimal maxAmount; // Taker bot's max acceptable transaction fee rate. private final long maxTxFeeRate; - // Maximum # of offers to take during one bot session (shut down bot after N swaps). + // Maximum # of offers to take during one bot session (shut down bot after taking N swap offers). private final int maxTakeOffers; // Offer polling frequency must be > 1000 ms between each getoffers request. private final long pollingInterval; - // The # of BSQ swap offers taken during the bot session (since startup). + // The # of offers taken during the bot session (since startup). private int numOffersTaken = 0; public TakeBestPricedOfferToSellBsq(String[] args) { @@ -74,7 +70,6 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available. this.configFile = loadConfigFile(); this.paymentAccount = getBsqSwapPaymentAccount(); - this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode(); this.maxMarketPriceMargin = new BigDecimal(configFile.getProperty("maxMarketPriceMargin")) .setScale(2, HALF_UP); this.regtest30DayAvgBsqPrice = new BigDecimal(configFile.getProperty("regtest30DayAvgBsqPrice")) @@ -105,7 +100,7 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { } // Get all available and takeable offers, sorted by price ascending. - var offers = getOffers(BUY.name(), currencyCode).stream() + var offers = getOffers(BUY.name(), CURRENCY_CODE).stream() .filter(o -> !isAlreadyTaken.test(o)) .toList(); @@ -142,7 +137,7 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { if (isDryRun) { addToOffersTaken(offer); numOffersTaken++; - maybeShutdownAfterSuccessfulSwap(); + maybeShutdownAfterSuccessfulSwap(numOffersTaken, maxTakeOffers); } else { // An encrypted wallet must be unlocked before calling takeoffer and gettrade. // Unlock the wallet for 10 minutes. If the wallet is already unlocked, @@ -160,11 +155,11 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { printBSQBalances("BSQ Balances After Swap Execution"); numOffersTaken++; - maybeShutdownAfterSuccessfulSwap(); + maybeShutdownAfterSuccessfulSwap(numOffersTaken, maxTakeOffers); } catch (NonFatalException nonFatalException) { - handleNonFatalException(nonFatalException); + handleNonFatalException(nonFatalException, pollingInterval); } catch (StatusRuntimeException fatalException) { - handleFatalException(fatalException); + handleFatalBsqSwapException(fatalException); } } } @@ -179,7 +174,7 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { configsByLabel.put("My Payment Account:", ""); configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); - configsByLabel.put("\tCurrency Code:", currencyCode); + configsByLabel.put("\tCurrency Code:", CURRENCY_CODE); configsByLabel.put("Trading Rules:", ""); configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers); configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte"); @@ -200,53 +195,6 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { log.info(toTable.apply("Bot Configuration", configsByLabel)); } - /** - * Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0. - */ - private void handleNonFatalException(NonFatalException nonFatalException) { - log.warn(nonFatalException.getMessage()); - if (nonFatalException.hasStallTime()) { - long stallTime = nonFatalException.getStallTime(); - log.warn("A minute must pass between the previous and the next takeoffer attempt." - + " Stalling for {} seconds before the next takeoffer attempt.", - toSeconds.apply(stallTime + pollingInterval)); - runCountdown(log, stallTime); - } else { - runCountdown(log, pollingInterval); - } - } - - /** - * Log the fatal exception, and shut down daemon and bot. - */ - private void handleFatalException(StatusRuntimeException fatalException) { - log.error("", fatalException); - shutdownAfterFatalError("Shutting down API daemon and bot after failing to execute BSQ swap."); - } - - /** - * Lock the wallet, stop the API daemon, and terminate the bot. - */ - private void maybeShutdownAfterSuccessfulSwap() { - log.info("Here are today's completed trades:"); - printTradesSummaryForToday(CLOSED); - - if (!isDryRun) { - try { - lockWallet(); - } catch (NonFatalException ex) { - log.warn(ex.getMessage()); - } - } - if (numOffersTaken >= maxTakeOffers) { - isShutdown = true; - log.info("Shutting down API bot after executing {} BSQ swaps.", numOffersTaken); - exit(0); - } else { - log.info("You have completed {} BSQ swap(s) during this bot session.", numOffersTaken); - } - } - /** * Return true is fixed-price offer's price <= the bot's max market price margin. Allows bot to take a * fixed-priced offer if the price is <= {@link #maxMarketPriceMargin} (%) of the current market price. @@ -257,13 +205,6 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { currentMarketPrice, getMaxMarketPriceMargin()); - /** - * Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries). - * TODO API's takeoffer needs to support taking offer's minAmount. - */ - protected final Predicate isWithinBTCAmountBounds = (offer) -> - BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount()); - public static void main(String[] args) { TakeBestPricedOfferToSellBsq bot = new TakeBestPricedOfferToSellBsq(args); bot.run(); @@ -298,28 +239,28 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) .filter(isMakerPreferredTradingPeer) .filter(o -> isFixedPriceLEMaxMarketPriceMargin.test(o, avgBsqPrice)) - .filter(isWithinBTCAmountBounds) + .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount())) .findFirst(); else return offers.stream() .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) .filter(o -> isFixedPriceLEMaxMarketPriceMargin.test(o, avgBsqPrice)) - .filter(isWithinBTCAmountBounds) + .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount())) .findFirst(); } void printCriteriaSummary() { if (isZero.test(maxMarketPriceMargin)) { - log.info("Looking for offers to {}, with a fixed-price at or less than" + log.info("Looking for offers to {}, with a fixed-price at or lower than" + " the 30-day average BSQ trade price of {} BTC.", MARKET_DESCRIPTION, avgBsqPrice); } else { - log.info("Looking for offers to {}, with a fixed-price at or less than" + log.info("Looking for offers to {}, with a fixed-price at or lower than" + " {}% {} the 30-day average BSQ trade price of {} BTC.", MARKET_DESCRIPTION, maxMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below". - aboveOrBelowMarketPrice.apply(maxMarketPriceMargin), + aboveOrBelowMaxMarketPriceMargin.apply(maxMarketPriceMargin), avgBsqPrice); } } @@ -345,12 +286,12 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO" : "N/A"); var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's minimum price (%s)?", - offer.getPrice() + " " + currencyCode, - targetPrice + " " + currencyCode); + offer.getPrice() + " BTC", + targetPrice + " BTC"); filterResultsByLabel.put(fixedPriceLabel, isFixedPriceLEMaxMarketPriceMargin.test(offer, avgBsqPrice)); var btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount); filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?", - isWithinBTCAmountBounds.test(offer)); + isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount())); var title = format("Fixed price BSQ swap offer %s filter results:", offer.getId()); log.info(toTable.apply(title, filterResultsByLabel)); diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBtc.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBtc.java index a6bd5b5..dd9893f 100644 --- a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBtc.java +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBtc.java @@ -25,14 +25,10 @@ import protobuf.PaymentAccount; import java.math.BigDecimal; import java.util.*; import java.util.function.BiPredicate; -import java.util.function.Predicate; -import java.util.function.Supplier; import static bisq.bots.BotUtils.*; import static java.lang.String.format; -import static java.lang.System.exit; import static java.math.RoundingMode.HALF_UP; -import static protobuf.OfferDirection.BUY; import static protobuf.OfferDirection.SELL; /** @@ -91,21 +87,21 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot { private final String currencyCode; // Taker bot's max market price margin. A takeable offer's price margin (%) must be <= maxMarketPriceMargin (%). private final BigDecimal maxMarketPriceMargin; - // Taker bot's min BTC amount to buy (or sell in case of XMR). A takeable offer's amount must be >= minAmount BTC. + // Taker bot's min BTC amount to trade. A takeable offer's amount must be >= minAmount BTC. private final BigDecimal minAmount; - // Taker bot's max BTC amount to buy (or sell in case of XMR). A takeable offer's amount must be <= maxAmount BTC. + // Taker bot's max BTC amount to trade. A takeable offer's amount must be <= maxAmount BTC. private final BigDecimal maxAmount; // Taker bot's max acceptable transaction fee rate. private final long maxTxFeeRate; // Taker bot's trading fee currency code (BSQ or BTC). private final String bisqTradeFeeCurrency; - // Maximum # of offers to take during one bot session (shut down bot after N swaps). + // Maximum # of offers to take during one bot session (shut down bot after taking N offers). private final int maxTakeOffers; // Offer polling frequency must be > 1000 ms between each getoffers request. private final long pollingInterval; - // The # of BSQ swap offers taken during the bot session (since startup). + // The # of offers taken during the bot session (since startup). private int numOffersTaken = 0; public TakeBestPricedOfferToSellBtc(String[] args) { @@ -145,12 +141,9 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot { continue; } - // Taker bot's getOffers(direction) request param. For fiat offers, is SELL (BTC), for XMR offers, is BUY (BTC). - String offerDirection = isXmr.test(currencyCode) ? BUY.name() : SELL.name(); - - // Get all available and takeable offers, sorted by price ascending. + // Get all available and takeable sell BTC offers, sorted by price ascending. // The list contains both fixed-price and market price margin based offers. - var offers = getOffers(offerDirection, currencyCode).stream() + var offers = getOffers(SELL.name(), currencyCode).stream() .filter(o -> !isAlreadyTaken.test(o)) .toList(); @@ -192,7 +185,7 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot { if (isDryRun) { addToOffersTaken(offer); numOffersTaken++; - maybeShutdownAfterSuccessfulTradeCreation(); + maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers); } else { // An encrypted wallet must be unlocked before calling takeoffer and gettrade. // Unlock the wallet for 5 minutes. If the wallet is already unlocked, @@ -213,81 +206,15 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot { printBTCBalances("BTC Balances After Simulated Trade Completion"); } numOffersTaken++; - maybeShutdownAfterSuccessfulTradeCreation(); + maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers); } catch (NonFatalException nonFatalException) { - handleNonFatalException(nonFatalException); + handleNonFatalException(nonFatalException, pollingInterval); } catch (StatusRuntimeException fatalException) { - handleFatalException(fatalException); + shutdownAfterTakeOfferFailure(fatalException); } } } - /** - * Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0. - */ - private void handleNonFatalException(NonFatalException nonFatalException) { - log.warn(nonFatalException.getMessage()); - if (nonFatalException.hasStallTime()) { - long stallTime = nonFatalException.getStallTime(); - log.warn("A minute must pass between the previous and the next takeoffer attempt." - + " Stalling for {} seconds before the next takeoffer attempt.", - toSeconds.apply(stallTime + pollingInterval)); - runCountdown(log, stallTime); - } else { - runCountdown(log, pollingInterval); - } - } - - /** - * Log the fatal exception, and shut down daemon and bot. - */ - private void handleFatalException(StatusRuntimeException fatalException) { - log.error("", fatalException); - shutdownAfterFailedTradePreparation(); - } - - /** - * Lock the wallet, stop the API daemon, and terminate the bot. - */ - private void maybeShutdownAfterSuccessfulTradeCreation() { - if (!isDryRun) { - try { - lockWallet(); - } catch (NonFatalException ex) { - log.warn(ex.getMessage()); - } - } - if (numOffersTaken >= maxTakeOffers) { - isShutdown = true; - - if (canSimulatePaymentSteps) { - log.info("Shutting down bot after {} successful simulated trades." - + " API daemon will not be shut down.", - numOffersTaken); - sleep(2_000); - } else { - log.info("Shutting down API daemon and bot after taking {} offers." - + " Complete the trade(s) with the desktop UI.", - numOffersTaken); - sleep(2_000); - log.info("Sending stop request to daemon."); - stopDaemon(); - } - - exit(0); - - } else { - log.info("You have taken {} offers during this bot session.", numOffersTaken); - } - } - - /** - * Lock the wallet, stop the API daemon, and terminate the bot with a non-zero status (error). - */ - private void shutdownAfterFailedTradePreparation() { - shutdownAfterFatalError("Shutting down API daemon and bot after failing to find new trade."); - } - /** * Return true is fixed-price offer's price <= the bot's max market price margin. Allows bot to take a * fixed-priced offer if the price is <= {@link #maxMarketPriceMargin} (%) of the current market price. @@ -298,13 +225,6 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot { currentMarketPrice, getMaxMarketPriceMargin()); - /** - * Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries). - * TODO API's takeoffer needs to support taking offer's minAmount. - */ - protected final Predicate isWithinBTCAmountBounds = (offer) -> - BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount()); - private void printBotConfiguration() { var configsByLabel = new LinkedHashMap(); configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion()); @@ -339,17 +259,12 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot { * performs candidate offer filtering, and provides useful log statements. */ private class TakeCriteria { + private static final String MARKET_DESCRIPTION = "Sell BTC"; + private final BigDecimal currentMarketPrice; @Getter private final BigDecimal targetPrice; - private final Supplier marketDescription = () -> { - if (isXmr.test(currencyCode)) - return "Sell XMR (Buy BTC)"; - else - return "Sell BTC"; - }; - public TakeCriteria() { this.currentMarketPrice = getCurrentMarketPrice(currencyCode); this.targetPrice = calcTargetPrice(maxMarketPriceMargin, currentMarketPrice, currencyCode); @@ -368,39 +283,39 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot { .filter(isMakerPreferredTradingPeer) .filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin) || isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice)) - .filter(isWithinBTCAmountBounds) + .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount())) .findFirst(); else return offers.stream() .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) .filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin) || isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice)) - .filter(isWithinBTCAmountBounds) + .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount())) .findFirst(); } void printCriteriaSummary() { if (isZero.test(maxMarketPriceMargin)) { - log.info("Looking for offers to {}, priced at or lower than the current market price {} {}.", - marketDescription.get(), + log.info("Looking for offers to {}, priced at or lower than the current market price of {} {}.", + MARKET_DESCRIPTION, currentMarketPrice, - isXmr.test(currencyCode) ? "BTC" : currencyCode); + currencyCode); } else { - log.info("Looking for offers to {}, priced at or less than {}% {} the current market price {} {}.", - marketDescription.get(), + log.info("Looking for offers to {}, priced at or lower than {}% {} the current market price of {} {}.", + MARKET_DESCRIPTION, maxMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below". - aboveOrBelowMarketPrice.apply(maxMarketPriceMargin), + aboveOrBelowMaxMarketPriceMargin.apply(maxMarketPriceMargin), currentMarketPrice, - isXmr.test(currencyCode) ? "BTC" : currencyCode); + currencyCode); } } void printOffersAgainstCriteria(List offers) { log.info("Currently available {} offers -- want to take {} offer with price <= {} {}.", - marketDescription.get(), + MARKET_DESCRIPTION, currencyCode, targetPrice, - isXmr.test(currencyCode) ? "BTC" : currencyCode); + currencyCode); printOffersSummary(offers); } @@ -417,23 +332,22 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot { iHavePreferredTradingPeers.get() ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO" : "N/A"); - var marginPriceLabel = format("Is offer's price margin (%s%%) <= bot's max market price margin (%s%%)?", - offer.getMarketPriceMarginPct(), - maxMarketPriceMargin); - filterResultsByLabel.put(marginPriceLabel, - offer.getUseMarketBasedPrice() - ? isMarginLEMaxMarketPriceMargin.test(offer, maxMarketPriceMargin) - : "N/A"); - var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's target price (%s)?", - offer.getUseMarketBasedPrice() ? "N/A" : offer.getPrice() + " " + currencyCode, - offer.getUseMarketBasedPrice() ? "N/A" : targetPrice + " " + currencyCode); - filterResultsByLabel.put(fixedPriceLabel, - offer.getUseMarketBasedPrice() - ? "N/A" - : isFixedPriceLEMaxMarketPriceMargin.test(offer, currentMarketPrice)); + + if (offer.getUseMarketBasedPrice()) { + var marginPriceLabel = format("Is offer's price margin (%s%%) <= bot's max market price margin (%s%%)?", + offer.getMarketPriceMarginPct(), + maxMarketPriceMargin); + filterResultsByLabel.put(marginPriceLabel, isMarginLEMaxMarketPriceMargin.test(offer, maxMarketPriceMargin)); + } else { + var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's target price (%s)?", + offer.getPrice() + " " + currencyCode, + targetPrice + " " + currencyCode); + filterResultsByLabel.put(fixedPriceLabel, isFixedPriceLEMaxMarketPriceMargin.test(offer, currentMarketPrice)); + } + String btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount); filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?", - isWithinBTCAmountBounds.test(offer)); + isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount())); var title = format("%s offer %s filter results:", offer.getUseMarketBasedPrice() ? "Margin based" : "Fixed price", diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellXmr.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellXmr.java new file mode 100644 index 0000000..06a725e --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellXmr.java @@ -0,0 +1,329 @@ +/* + * 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.bots; + +import bisq.proto.grpc.OfferInfo; +import io.grpc.StatusRuntimeException; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import protobuf.PaymentAccount; + +import java.math.BigDecimal; +import java.util.*; +import java.util.function.BiPredicate; + +import static bisq.bots.BotUtils.*; +import static java.lang.String.format; +import static java.math.RoundingMode.HALF_UP; +import static protobuf.OfferDirection.BUY; + +/** + * The use case for the TakeBestPricedOfferToSellXmr bot is to buy XMR with BTC at a low BTC price, e.g.: + *

+ *      Take an offer to sell you XMR for BTC, at no more than #.##% above or below current market price if:
+ *          the offer maker is a preferred trading peer,
+ *          and the offer's BTC amount is between 0.50 and 1.00 BTC,
+ *          and the current transaction mining fee rate is below 15 sats / byte.
+ *
+ * Usage:  TakeBestPricedOfferToSellXmr  --password=api-password --port=api-port \
+ *                          [--conf=take-best-priced-offer-to-sell-xmr.conf] \
+ *                          [--dryrun=true|false]
+ *                          [--simulate-regtest-payment=true|false]
+ * 
+ *

+ * The criteria for determining which offers to take are defined in the bot's configuration file + * TakeBestPricedOfferToSellXmr.properties (located in project's src/main/resources directory). The individual + * configurations are commented in the existing TakeBestPricedOfferToSellXmr.properties, which should be used as a + * template for your own use case. + */ +@Slf4j +@Getter +public class TakeBestPricedOfferToSellXmr extends AbstractBot { + + // Taker bot's XMR payment account trading currency code. + private static final String CURRENCY_CODE = "XMR"; + + // Config file: resources/TakeBestPricedOfferToSellXmr.properties. + private final Properties configFile; + // Taker bot's XMR payment account (if the configured paymentAccountId is valid). + private final PaymentAccount paymentAccount; + // Taker bot's maximum market price margin. A takeable offer's price margin (%) must be <= maxMarketPriceMargin (%). + private final BigDecimal maxMarketPriceMargin; + // Taker bot's min BTC amount to trade. A takeable offer's amount must be >= minAmount BTC. + private final BigDecimal minAmount; + // Taker bot's max BTC amount to trade. A takeable offer's amount must be <= maxAmount BTC. + private final BigDecimal maxAmount; + // Taker bot's max acceptable transaction fee rate. + private final long maxTxFeeRate; + // Taker bot's trading fee currency code (BSQ or BTC). + private final String bisqTradeFeeCurrency; + // Maximum # of offers to take during one bot session (shut down bot after taking N offers). + private final int maxTakeOffers; + + // Offer polling frequency must be > 1000 ms between each getoffers request. + private final long pollingInterval; + + // The # of offers taken during the bot session (since startup). + private int numOffersTaken = 0; + + public TakeBestPricedOfferToSellXmr(String[] args) { + super(args); + pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available. + this.configFile = loadConfigFile(); + this.paymentAccount = getPaymentAccount(configFile.getProperty("paymentAccountId")); + this.maxMarketPriceMargin = new BigDecimal(configFile.getProperty("maxMarketPriceMargin")) + .setScale(2, HALF_UP); + this.minAmount = new BigDecimal(configFile.getProperty("minAmount")); + this.maxAmount = new BigDecimal(configFile.getProperty("maxAmount")); + this.maxTxFeeRate = Long.parseLong(configFile.getProperty("maxTxFeeRate")); + this.bisqTradeFeeCurrency = configFile.getProperty("bisqTradeFeeCurrency"); + this.maxTakeOffers = Integer.parseInt(configFile.getProperty("maxTakeOffers")); + loadPreferredOnionAddresses.accept(configFile, preferredTradingPeers); + this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval")); + } + + /** + * Checks for the most attractive offer to take every {@link #pollingInterval} ms. After {@link #maxTakeOffers} + * are taken, bot will stop the API daemon, then shut itself down, prompting the user to start the desktop UI + * to complete the trade. + */ + @Override + public void run() { + var startTime = new Date().getTime(); + validateWalletPassword(walletPassword); + validatePollingInterval(pollingInterval); + validateTradeFeeCurrencyCode(bisqTradeFeeCurrency); + validatePaymentAccount(paymentAccount, CURRENCY_CODE); + printBotConfiguration(); + + while (!isShutdown) { + if (!isBisqNetworkTxFeeRateLowEnough.test(maxTxFeeRate)) { + runCountdown(log, pollingInterval); + continue; + } + + // Get all available and takeable buy BTC for XMR offers, sorted by price ascending. + // The list may contain both fixed-price and market price margin based offers. + var offers = getOffers(BUY.name(), CURRENCY_CODE).stream() + .filter(o -> !isAlreadyTaken.test(o)) + .toList(); + + if (offers.isEmpty()) { + log.info("No takeable offers found."); + runCountdown(log, pollingInterval); + continue; + } + + // Define criteria for taking an offer, based on conf file. + TakeCriteria takeCriteria = new TakeCriteria(); + takeCriteria.printCriteriaSummary(); + takeCriteria.printOffersAgainstCriteria(offers); + + // Find takeable offer based on criteria. + Optional selectedOffer = takeCriteria.findTakeableOffer(offers); + // Try to take the offer, if found, or say 'no offer found' before going to sleep. + selectedOffer.ifPresentOrElse(offer -> takeOffer(takeCriteria, offer), + () -> { + var cheapestOffer = offers.get(0); + log.info("No acceptable offer found. Closest possible candidate did not pass filters:"); + takeCriteria.printOfferAgainstCriteria(cheapestOffer); + }); + + printDryRunProgress(); + runCountdown(log, pollingInterval); + pingDaemon(startTime); + } + } + + /** + * Attempt to take the available offer according to configured criteria. If successful, will block until a new + * trade is fully initialized with a trade contract. Otherwise, handles a non-fatal error and allows the bot to + * stay alive, or shuts down the bot upon fatal error. + */ + private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { + log.info("Will attempt to take offer '{}'.", offer.getId()); + takeCriteria.printOfferAgainstCriteria(offer); + if (isDryRun) { + addToOffersTaken(offer); + numOffersTaken++; + maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers); + } else { + // An encrypted wallet must be unlocked before calling takeoffer and gettrade. + // Unlock the wallet for 5 minutes. If the wallet is already unlocked, + // this command will override the timeout of the previous unlock command. + try { + unlockWallet(walletPassword, 600); + printBTCBalances("BTC Balances Before Take Offer Attempt"); + // Blocks until new trade is prepared, or times out. + takeV1ProtocolOffer(offer, paymentAccount, bisqTradeFeeCurrency, pollingInterval); + printBTCBalances("BTC Balances After Take Offer Attempt"); + + if (canSimulatePaymentSteps) { + var newTrade = getTrade(offer.getId()); + RegtestTradePaymentSimulator tradePaymentSimulator = new RegtestTradePaymentSimulator(args, + newTrade.getTradeId(), + paymentAccount); + tradePaymentSimulator.run(); + printBTCBalances("BTC Balances After Simulated Trade Completion"); + } + numOffersTaken++; + maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers); + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException, pollingInterval); + } catch (StatusRuntimeException fatalException) { + shutdownAfterTakeOfferFailure(fatalException); + } + } + } + + /** + * Return true is fixed-price offer's price <= the bot's max market price margin. Allows bot to take a + * fixed-priced offer if the price is <= {@link #maxMarketPriceMargin} (%) of the current market price. + */ + protected final BiPredicate isFixedPriceLEMaxMarketPriceMargin = + (offer, currentMarketPrice) -> BotUtils.isFixedPriceLEMaxMarketPriceMargin( + offer, + currentMarketPrice, + this.getMaxMarketPriceMargin()); + + private void printBotConfiguration() { + var configsByLabel = new LinkedHashMap(); + configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion()); + var network = getNetwork(); + configsByLabel.put("BTC Network:", network); + configsByLabel.put("My Payment Account:", ""); + configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); + configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); + configsByLabel.put("\tCurrency Code:", CURRENCY_CODE); + configsByLabel.put("Trading Rules:", ""); + configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers); + configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte"); + configsByLabel.put("\tMax Market Price Margin:", maxMarketPriceMargin + "%"); + configsByLabel.put("\tMin BTC Amount:", minAmount + " BTC"); + configsByLabel.put("\tMax BTC Amount: ", maxAmount + " BTC"); + if (iHavePreferredTradingPeers.get()) { + configsByLabel.put("\tPreferred Trading Peers:", preferredTradingPeers.toString()); + } else { + configsByLabel.put("\tPreferred Trading Peers:", "N/A"); + } + configsByLabel.put("Bot Polling Interval:", pollingInterval + " ms"); + log.info(toTable.apply("Bot Configuration", configsByLabel)); + } + + public static void main(String[] args) { + TakeBestPricedOfferToSellXmr bot = new TakeBestPricedOfferToSellXmr(args); + bot.run(); + } + + /** + * Calculates additional takeoffer criteria based on conf file values, + * performs candidate offer filtering, and provides useful log statements. + */ + private class TakeCriteria { + private static final String MARKET_DESCRIPTION = "Sell XMR (Buy BTC)"; + + private final BigDecimal currentMarketPrice; + @Getter + private final BigDecimal targetPrice; + + public TakeCriteria() { + this.currentMarketPrice = getCurrentMarketPrice(CURRENCY_CODE); + this.targetPrice = calcTargetPrice(maxMarketPriceMargin, currentMarketPrice, CURRENCY_CODE); + } + + /** + * Returns the lowest priced offer passing the filters, or Optional.empty() if not found. + * The max tx fee rate filtering should have passed prior to calling this method. + * + * @param offers to filter + */ + Optional findTakeableOffer(List offers) { + if (iHavePreferredTradingPeers.get()) + return offers.stream() + .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) + .filter(isMakerPreferredTradingPeer) + .filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin) + || isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice)) + .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount())) + .findFirst(); + else + return offers.stream() + .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) + .filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin) + || isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice)) + .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount())) + .findFirst(); + } + + void printCriteriaSummary() { + if (isZero.test(maxMarketPriceMargin)) { + log.info("Looking for offers to {}, priced at or lower than the current market price of {} BTC.", + MARKET_DESCRIPTION, + currentMarketPrice); + } else { + log.info("Looking for offers to {}, priced at or lower than {}% {} the current market price of {} BTC.", + MARKET_DESCRIPTION, + maxMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below". + aboveOrBelowMaxMarketPriceMargin.apply(maxMarketPriceMargin), + currentMarketPrice); + } + } + + void printOffersAgainstCriteria(List offers) { + log.info("Currently available {} offers -- want to take {} offer with price <= {} BTC.", + MARKET_DESCRIPTION, + CURRENCY_CODE, + targetPrice); + printOffersSummary(offers); + } + + void printOfferAgainstCriteria(OfferInfo offer) { + printOfferSummary(offer); + + var filterResultsByLabel = new LinkedHashMap(); + filterResultsByLabel.put("Current Market Price:", currentMarketPrice + " " + CURRENCY_CODE); + filterResultsByLabel.put("Target Price (Max):", targetPrice + " " + CURRENCY_CODE); + filterResultsByLabel.put("Offer Price:", offer.getPrice() + " " + CURRENCY_CODE); + filterResultsByLabel.put("Offer maker used same payment method?", + usesSamePaymentMethod.test(offer, getPaymentAccount())); + filterResultsByLabel.put("Is offer maker a preferred trading peer?", + iHavePreferredTradingPeers.get() + ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO" + : "N/A"); + + if (offer.getUseMarketBasedPrice()) { + var marginPriceLabel = format("Is offer's price margin (%s%%) <= bot's max market price margin (%s%%)?", + offer.getMarketPriceMarginPct(), + maxMarketPriceMargin); + filterResultsByLabel.put(marginPriceLabel, isMarginLEMaxMarketPriceMargin.test(offer, maxMarketPriceMargin)); + } else { + var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's target price (%s)?", + offer.getPrice() + " BTC", + targetPrice + " BTC"); + filterResultsByLabel.put(fixedPriceLabel, isFixedPriceLEMaxMarketPriceMargin.test(offer, currentMarketPrice)); + } + String btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount); + filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?", + isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount())); + + var title = format("%s offer %s filter results:", + offer.getUseMarketBasedPrice() ? "Margin based" : "Fixed price", + offer.getId()); + log.info(toTable.apply(title, filterResultsByLabel)); + } + } +} diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToBuyXmr.properties b/java-examples/src/main/resources/TakeBestPricedOfferToBuyXmr.properties new file mode 100644 index 0000000..365074d --- /dev/null +++ b/java-examples/src/main/resources/TakeBestPricedOfferToBuyXmr.properties @@ -0,0 +1,28 @@ +# Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon). +maxTakeOffers=50 +# +# Taker bot's payment account id. Only SELL BTC offers using the same payment method will be considered for taking. +paymentAccountId=a15d0f15-e355-4b89-8322-e262097623ae +# +# Taker bot's min market price margin. A candidate sell BTC offer's price margin must be >= minMarketPriceMargin. +minMarketPriceMargin=0 +# +# Taker bot's min BTC amount to sell. The candidate SELL BTC offer's amount must be >= minAmount BTC. +minAmount=0.01 +# +# Taker bot's max BTC amount to sell. The candidate SELL BTC offer's amount must be <= maxAmount BTC. +maxAmount=0.90 +# +# Taker bot's max acceptable transaction fee rate (sats / byte). +# Regtest fee rates are from https://price.bisq.wiz.biz/getFees +maxTxFeeRate=25 +# +# Bisq trade fee currency code (BSQ or BTC). +bisqTradeFeeCurrency=BSQ +# +# Taker bot's list of preferred trading peers (their onion addresses). +# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character. +preferredTradingPeers=localhost:8888 +# +# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. +pollingInterval=30000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToSellXmr.properties b/java-examples/src/main/resources/TakeBestPricedOfferToSellXmr.properties new file mode 100644 index 0000000..28326d2 --- /dev/null +++ b/java-examples/src/main/resources/TakeBestPricedOfferToSellXmr.properties @@ -0,0 +1,28 @@ +# Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon). +maxTakeOffers=1 +# +# Taker bot's payment account id. Only SELL BTC offers using the same payment method will be considered for taking. +paymentAccountId=a15d0f15-e355-4b89-8322-e262097623ae +# +# Taker bot's max market price margin. A candidate buy BTC offer's price margin must be <= maxMarketPriceMargin. +maxMarketPriceMargin=1.10 +# +# Taker bot's min BTC amount to buy. The candidate buy BTC offer's amount must be >= minAmount BTC. +minAmount=0.01 +# +# Taker bot's max BTC amount to buy. The candidate buy BTC offer's amount must be <= maxAmount BTC. +maxAmount=0.90 +# +# Taker bot's max acceptable transaction fee rate (sats / byte). +# Regtest fee rates are from https://price.bisq.wiz.biz/getFees +maxTxFeeRate=25 +# +# Bisq trade fee currency code (BSQ or BTC). +bisqTradeFeeCurrency=BSQ +# +# Taker bot's list of preferred trading peers (their onion addresses). +# If you do not want to constrict trading to preferred peers, comment this line out with a '#' character. +preferredTradingPeers=localhost:8888 +# +# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. +pollingInterval=30000