Refactor the TakeBestPricedOfferTo*Btc bots

The TakeBestPricedOfferTo*Btc needed to be split up so they are specific
to fiat offers, and this change adds new TakeBestPricedOfferTo*Xmr bots
just for XMR.

This simplifies the the non-bsq-swap bots;  documenting, coding,
configuring, and logging the taking of both fiat and xmr (altcoin)
offers in the same bots was getting confusing, and that's going to
turn away potential users.

This refactoring means there are six bots now, and it forced considerable
refactoring in all of them.

This commit is the bulk of the work for the PR, but there will be more
changes after everything is tested again, and comments are adjusted.
This commit is contained in:
ghubstan 2022-06-28 19:52:05 -03:00
parent 0148f2ab66
commit 49a8c61bd2
No known key found for this signature in database
GPG Key ID: E35592D6800A861E
10 changed files with 1033 additions and 437 deletions

View File

@ -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<String> 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<OfferInfo> offersTaken = new ArrayList<>();
// This list should stay empty when dryRun = false.
protected final List<OfferInfo> offersTakenDuringDryRun = new ArrayList<>();
protected final boolean canUseBash = getBashPath().isPresent();
protected boolean isShutdown = false;
@ -82,9 +85,20 @@ public abstract class AbstractBot {
protected final Supplier<Long> minimumTxFeeRate = () -> txFeeRates.get().getMinFeeServiceRate();
protected final Supplier<Long> mostRecentTxFeeRate = () -> txFeeRates.get().getFeeServiceRate();
// Constructor
/**
* Constructor that optionally prompts user to enter wallet password in the console.
* <p>
* 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.
* <p>
*
* @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<OfferInfo> 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:
* <p>
* If numOffersTaken >= maxTakeOffers, and trade completion is being simulated on regtest, shut down the bot.
* <p>
* If numOffersTaken >= maxTakeOffers, and mainnet trade completion must be delegated to the UI, shut down the
* API daemon and the bot.
* <p>
* 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:
* <p>
* If numOffersTaken >= maxTakeOffers, and trade completion is being simulated on regtest, shut down the bot.
* <p>
* If numOffersTaken >= maxTakeOffers, and mainnet trade completion must be delegated to the UI, shut down the
* API daemon and the bot.
* <p>
* 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.
*

View File

@ -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<OfferInfo, BigDecimal> 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<BigDecimal, String> aboveOrBelowMinMarketPriceMargin = (minMarketPriceMargin) ->
minMarketPriceMargin.compareTo(ZERO) >= 0 ? "above" : "below";
/**
* Return String "below" if maxMarketPriceMargin (%) <= 0.00, else "above".
*/
public static final Function<BigDecimal, String> aboveOrBelowMarketPrice = (maxMarketPriceMargin) ->
public static final Function<BigDecimal, String> 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<String[]> 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.

View File

@ -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.
* <p>
* 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<OfferInfo, BigDecimal> 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<OfferInfo> 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));

View File

@ -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.
* <p>
@ -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.
* </pre>
* <p>
* 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.)
* <pre>
* 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.
* </pre>
* <p>
* <pre>
*
* 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<OfferInfo> isWithinBTCAmountBounds = (offer) ->
BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
private void printBotConfiguration() {
var configsByLabel = new LinkedHashMap<String, Object>();
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<String> 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<OfferInfo> 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",

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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.:
* <pre>
* 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]
* </pre>
* <p>
* 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<OfferInfo> 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<OfferInfo, BigDecimal> isFixedPriceGEMinMarketPriceMargin =
(offer, currentMarketPrice) -> BotUtils.isFixedPriceGEMinMarketPriceMargin(
offer,
currentMarketPrice,
this.getMinMarketPriceMargin());
private void printBotConfiguration() {
var configsByLabel = new LinkedHashMap<String, Object>();
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<OfferInfo> findTakeableOffer(List<OfferInfo> 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<OfferInfo> 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<String, Object>();
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));
}
}
}

View File

@ -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.
* <p>
* 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<OfferInfo> 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));

View File

@ -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<OfferInfo> isWithinBTCAmountBounds = (offer) ->
BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
private void printBotConfiguration() {
var configsByLabel = new LinkedHashMap<String, Object>();
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<String> 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<OfferInfo> 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",

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
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.:
* <pre>
* 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]
* </pre>
* <p>
* 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<OfferInfo> 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<OfferInfo, BigDecimal> isFixedPriceLEMaxMarketPriceMargin =
(offer, currentMarketPrice) -> BotUtils.isFixedPriceLEMaxMarketPriceMargin(
offer,
currentMarketPrice,
this.getMaxMarketPriceMargin());
private void printBotConfiguration() {
var configsByLabel = new LinkedHashMap<String, Object>();
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<OfferInfo> findTakeableOffer(List<OfferInfo> 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<OfferInfo> 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<String, Object>();
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));
}
}
}

View File

@ -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

View File

@ -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