diff --git a/README.md b/README.md index 07f2207..83337c0 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,23 @@ client example code, and developing new Java and Python clients and bots. It contains four subprojects: -1. [reference-doc-builder](https://github.com/bisq-network/bisq-api-reference/tree/main/reference-doc-builder) -- The Java - application that produces the [API Reference](https://bisq-network.github.io/slate) content, from Bisq protobuf +1. [reference-doc-builder](https://github.com/bisq-network/bisq-api-reference/tree/main/reference-doc-builder) -- The + Java application that produces the [API Reference](https://bisq-network.github.io/slate) content, from Bisq protobuf definition files. 2. [cli-examples](https://github.com/bisq-network/bisq-api-reference/tree/main/cli-examples) -- A folder of bash scripts demonstrating how to run API CLI commands. Each script is named for the RPC method call being demonstrated. 3. [java-examples](https://github.com/bisq-network/bisq-api-reference/tree/main/java-examples) -- A Java project - demonstrating how to call the API from Java gRPC clients. Each class in - the [bisq.rpccalls](https://github.com/bisq-network/bisq-api-reference/tree/main/java-examples/src/main/java/bisq/rpccalls) - package is named for the RPC method call being demonstrated. -4. [python-examples](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples) -- A Python3 project + demonstrating how to call the API from Java gRPC clients. Each class in the + [bisq.rpccalls](https://github.com/bisq-network/bisq-api-reference/tree/main/java-examples/src/main/java/bisq/rpccalls) + package is named for the RPC method call being demonstrated. There are also some mainnet-ready Java API bots in the + [bisq.bots](https://github.com/bisq-network/bisq-api-reference/tree/main/java-examples/src/main/java/bisq/bots) + package. +5. [python-examples](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples) -- A Python3 project demonstrating how to call the API from Python3 gRPC clients. Each class in - the [bisq.rpccalls](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples/bisq/rpccalls) package - is named for the RPC method call being demonstrated. There are also some simple bot examples in - the [bisq.bots](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples/bisq/bots) package. + the [bisq.rpccalls](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples/bisq/rpccalls) + package is named for the RPC method call being demonstrated. There are also some simple (not-ready-for-mainnet) bot + examples in the [bisq.bots](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples/bisq/bots) + package. The RPC method examples are also displayed in the [API Reference](https://bisq-network.github.io/slate). While navigating the RPC method links in the reference's table of contents on the left side of the page, they appear in the diff --git a/java-examples/README.md b/java-examples/README.md index 9106750..6d5cd0f 100644 --- a/java-examples/README.md +++ b/java-examples/README.md @@ -1,11 +1,404 @@ -# Bisq API Java Examples +# Bisq API Java Examples And Bots -This subproject contains Java classes demonstrating API gRPC method calls. +This subproject contains: + +* [Java API rpc examples](https://github.com/bisq-network/bisq-api-reference/tree/main/java-examples/src/main/java/bisq/rpccalls) + demonstrating how to send API gRPC requests, and handle responses. + +* [Java API bots](https://github.com/bisq-network/bisq-api-reference/tree/main/java-examples/src/main/java/bisq/bots) + to use as is on mainnet, and demonstrate how to write new API bots. If interested in porting any bot code to other + languages supported by [gRPC](https://grpc.io/docs/languages), please use these Java bot examples, not the Python + examples. The Python examples were written by a Python noob, and don't handle errors. + +* A [Gradle build file](https://github.com/bisq-network/bisq-api-reference/blob/main/java-examples/build.gradle) + that could be used as a template for your own Java API bot project. + +## [Risks, Warnings and Flaws](#risks-warnings-and-flaws) + +### Never Run API Daemon and [Bisq GUI](https://bisq.network) On Same Host At Same Time + +The API daemon and the GUI share the same default wallet and connection ports. Beyond inevitable failures due to +fighting over the wallet and ports, doing so will probably corrupt your wallet. Before starting the API daemon, make +sure your GUI is shut down, and vice-versa. Please back up your mainnet wallet early and often with the GUI. + +### Go Slow (But Much Faster Than You Click Buttons In The GUI) + +[Bisq](https://bisq.network) was designed to respond to manual clicks in the user interface. It is not a +high-throughput, high-performance system supporting atomic transactions. Care must be taken to avoid problems due to +slow wallet updates on your disk, and Tor network latency. The API daemon enforces limits on request frequency via call +rate metering, but you cannot assume bots can perform tasks as rapidly as the API daemon's call rate meters allow. + +### Run Bots On Mainnet At Your Own Risk + +This document would not state "these bots can be run on mainnet, as is" without reasonable confidence on the part of the +code's author, but if you do, you do so at your own risk. Copious details about running them on a local BTC regtest +network, running on mainnet in **dryrun** mode, and each bot's configuration is provided in this document. Please put +some effort into understanding a bot's code and its configuration before trying it on mainnet. + +### Why There Is Duplicated Code In The Bots + +The TakeBestPricedOffer* bots could be combined into a single class, and use multithreaded task scheduling instead of +loops with Thread sleep instructions, but we want them to be easily understood by people who are not necessarily +experienced Java coders (and be easily portable to +other [gRPC supported language bindings](https://grpc.io/docs/languages)). For non-developers, splitting up a +one-size-fits-all TakeBestPricedOffer also makes them easier to configure for various BTC/Fiat, XMR/BTC, and BSQ/BTC +market use cases. + +## [Generating Protobuf Code](#generating-protobuf-code) + +### Download IDL (.proto) Files From The [Bisq Repo](https://github.com/bisq-network/bisq) + +The protobuf IDL files are not part of this project, and must be downloaded from the Bisq repository's +[protobuf file directory](https://github.com/bisq-network/bisq/tree/master/proto/src/main/proto). + +TODO @ripcurlx, please review https://github.com/ghubstan/bisq-api-reference/pull/11 + +You can download them by running +[this script](https://github.com/bisq-network/bisq-api-reference/blob/main/proto-downloader/download-bisq-protos.sh) +from your IDE or a shell: + +```asciidoc +$ proto-downloader/download-bisq-protos.sh +``` + +The java-examples [build file](https://github.com/bisq-network/bisq-api-reference/blob/main/java-examples/build.gradle) +will generate the code from the downloaded IDL (.proto) files. + +You should be able to generate the protobuf Java sources and all Java examples in your IDE. + +In a terminal: + +```asciidoc +$ cd java-examples $ ./gradlew clean build +``` + +## [Java API Method Examples](#java-api-method-examples) Each class in the [bisq.rpccalls](https://github.com/bisq-network/bisq-api-reference/tree/main/java-examples/src/main/java/bisq/rpccalls) package is named for the RPC method call being demonstrated. -The subproject uses a -a [Gradle build file](https://github.com/bisq-network/bisq-api-reference/blob/main/java-examples/build.gradle), also -demonstrating how to generate the necessary protobuf classes from the Bisq .proto files. +Their purpose is to show how to construct a gRPC service method request with parameters, send the request, and print a +response object if there is one. As a rule, the request is successful if a gRPC StatusRuntimeException is not thrown by +the API daemon. + +Their usage is simple; there are no program arguments for any of them. Just run them with an IDE program launcher or +your shell. However, you will usually need to edit the Java class and re-compile it before running it because these +examples know nothing about real Payment Account IDs, Offer IDs, etc. To run the +[GetOffer.java](https://github.com/bisq-network/bisq-api-reference/blob/main/java-examples/src/main/java/bisq/rpccalls/GetOffer.java) +example, your will need to change the hard-coded offer ID to a real offer ID to avoid a "not found" gRPC +StatusRuntimeException from the API daemon. + +## [Java API Bots](#java-api-bots) + +### Purpose + +The Java API bots in this project are meant to be run on mainnet, provide a base for more complex bots, and guide you in +developing your own bots. + +Put some effort into understanding a bot's code and its configuration before trying it on mainnet. While you get +familiar with a bot example, you can run it in **dryrun** mode to see how it behaves with different configurations +(more later). Even better: run it while your Bisq API daemons (seednode, arbitration-node, and a trading peer) are +connected to a local BTC regtest network. +The [API test harness](https://github.com/bisq-network/bisq/blob/master/apitest/docs/api-beta-test-guide.md) is +convenient for this. + +#### Quick And Dirty Test Harness + +If you are already familiar +with [building Bisq source code](https://github.com/bisq-network/bisq/blob/master/docs/build.md), and +have [bitcoin-core binaries](https://github.com/bitcoin/bitcoin) on your system's $PATH, you might skip the +[API test harness setup guide](https://github.com/bisq-network/bisq/blob/master/apitest/docs/api-beta-test-guide.md). + +Before you try the test harness, make sure your host is not running any bitcoind or Bisq nodes. + +Clone the Bisq master branch to your host, build and start it: + +```asciidoc +# Clone Bisq source repo. +$ git clone https://github.com/bisq-network/bisq.git [some folder] +$ cd [some folder] + +# Build Bisq source, install DAO/Regtest wallet files (with coins). +$ ./gradlew clean build :apitest:installDaoSetup -x test + +# Start local bitcoind (regtest) node and headless test harness nodes. +$ ./bisq-apitest --apiPassword=xyz --supportingApps=bitcoind,seednode,arbdaemon,alicedaemon,bobdaemon --shutdownAfterTests=false +``` + +To shut down the test harness, enter **^C**. + +### Take BSQ / BTC / XMR / Offer Bots + +There are six bots for taking offers: + +* [Take Best Priced Offer To Buy Btc](#take-best-priced-offer-to-buy-btc) +* [Take Best Priced Offer To Sell Btc](#take-best-priced-offer-to-sell-btc) + +* [Take Best Priced Offer To Buy Bsq](#take-best-priced-offer-to-buy-bsq) +* [Take Best Priced Offer To Sell Bsq](#take-best-priced-offer-to-sell-bsq) + +* [Take Best Priced Offer To Buy Xmr](#take-best-priced-offer-to-buy-xmr) +* [Take Best Priced Offer To Sell Xmr](#take-best-priced-offer-to-sell-xmr) + +The **Take Buy/Sell BTC and XMR Offer** bots take 1 or more offers for a given criteria as defined in each bot's +configuration file (more later). After the configured maximum number of offers have been taken, the bot shuts down the +API daemon and itself because trade payments are made outside Bisq. Bisq nodes (UI or API) do not communicate with your +banks and XMR wallets, and *cannot automate fiat and XMR trade payments and deposit confirmations*. + +The Bisq trade payment protocol steps of the Bisq protocol can be performed in the UI, or you can finish the trade with +an API daemon and manual CLI commands: + +```asciidoc +# If you are a BTC buyer, notify peer you have initiated fiat or XMR payment. +$ ./bisq-cli --password=xyz --port=9998 confirmpaymentstarted --trade-id= + +# If you are a BTC seller, notify peer your have received fiat or XMR payment. +$ ./bisq-cli --password=xyz --port=9998 confirmpaymentreceived --trade-id= + +# Close your completed trade (move it to your trade history). +$ ./bisq-cli --password=xyz --port=9998 closetrade --trade-id= +``` + +The **Take Buy/Sell BSQ** bots take 1 or more offers for a given criteria as defined in each bot's configuration file +(more later). After the configured maximum number of offers have been taken, the bot shuts down itself, but not the API +daemon. BSQ Swaps can be fully automated by the API because the swap transaction is completed within seconds of taking a +BSQ Swap offer. + +### [Take Best Priced Offer To Buy Btc](#take-best-priced-offer-to-buy-btc) + +**Purpose:** Sell your BTC at a higher fiat price + +This bot waits for attractively priced BUY BTC (with fiat) offers to appear, and takes 1 or more of them according to +the bot's configuration. The bot will consider only those offers created with the same payment method associated with +your bot's configured payment account id. + +The benefit this bot provides is freeing up user time spent watching the offer book in the UI, waiting for the right +offer to take. The disadvantage is having to perform some trade protocol steps outside the API daemon. +Fiat payment confirmations must be done as they are when using the Bisq UI -- outside the Bisq application. After bank +payments are confirmed in your online banking system, trades can be completed in the desktop UI, or via API CLI commands. + +**Warning** + +Take special care to not run the Bisq API daemon and the desktop application at the same time on the same host. + +**Use Cases, Usage and Configuration** + +This information is found in +the [source code's Java class level documentation](https://github.com/bisq-network/bisq-api-reference/blob/main/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBtc.java) +. + +**Creating And Using Runnable TakeBestPricedOfferToBuyBtc Jar** + +To create a runnable jar, see [Creating Runnable Jars](#creating-runnable-jars). To run the jar, edit the conf file for +your use case, and use the java -jar command: + +```asciidoc +$ java -jar take-best-priced-offer-to-buy-btc.jar \ + --password=xyz \ + --dryrun=false \ + --conf=take-best-priced-offer-to-buy-btc.conf +``` + +### [Take Best Priced Offer To Sell Btc](#take-best-priced-offer-to-sell-btc) + +**Purpose:** Buy BTC at a lower fiat price + +This bot waits for attractively priced SELL BTC (for fiat) offers to appear, and takes 1 or more of them according to +the bot's configuration. The bot will consider only those offers created with the same payment method associated with +your bot's configured payment account id. + +The benefit this bot provides is freeing up user time spent watching the offer book in the UI, waiting for the right +offer to take. Low-priced BTC offers are taken relatively quickly; this bot increases the chance of beating other nodes +to the offer. The disadvantage is having to perform some trade protocol steps outside the API daemon. Fiat +payments must be sent as they are when using the Bisq UI -- outside the Bisq application. After bank payments are made +in your online banking system, trades can be completed in the desktop UI, or via API CLI commands. + +**Warning** + +Take special care to not run the Bisq API daemon and the desktop application at the same time on the same host. + +**Use Cases, Usage and Configuration** + +This information is found in +the [source code's Java class level documentation](https://github.com/bisq-network/bisq-api-reference/blob/main/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBtc.java) +. + +**Creating And Using Runnable TakeBestPricedOfferToSellBtc Jar** + +To create a runnable jar, see [Creating Runnable Jars](#creating-runnable-jars). To run the jar, edit the conf file for +your use case, and use the java -jar command: + +```asciidoc +$ java -jar take-best-priced-offer-to-sell-btc.jar \ + --password=xyz \ + --dryrun=false \ + --conf=take-best-priced-offer-to-sell-btc.conf +``` + +### [Take Best Priced Offer To Buy Bsq](#take-best-priced-offer-to-buy-bsq) + +**Purpose:** Sell your BSQ at a higher BTC price + +This bot waits for attractively priced BUY BSQ (with BTC) offers to appear, and takes 1 or more of them according to +the bot's configuration. The bot will consider only those offers created with the same payment method associated with +your bot's configured payment account id. + +**Warning** + +Take special care to not run the Bisq API daemon and the desktop application at the same time on the same host. + +**Use Cases, Usage and Configuration** + +This information is found in +the [source code's Java class level documentation](https://github.com/bisq-network/bisq-api-reference/blob/main/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java) +. + +**Creating And Using Runnable TakeBestPricedOfferToBuyBsq Jar** + +To create a runnable jar, see [Creating Runnable Jars](#creating-runnable-jars). To run the jar, edit the conf file for +your use case, and use the java -jar command: + +```asciidoc +$ java -jar take-best-priced-offer-to-buy-bsq.jar \ + --password=xyz \ + --dryrun=false \ + --conf=take-best-priced-offer-to-buy-bsq.conf +``` + +### [Take Best Priced Offer To Sell Bsq](#take-best-priced-offer-to-sell-bsq) + +**Purpose:** Buy BSQ at a lower BTC price + +This bot waits for attractively priced SELL BSQ (for BTC) offers to appear, and takes 1 or more of them according to +the bot's configuration. The bot will consider only those offers created with the same payment method associated with +your bot's configured payment account id. + +**Warning** + +Take special care to not run the Bisq API daemon and the desktop application at the same time on the same host. + +**Use Cases, Usage and Configuration** + +This information is found in +the [source code's Java class level documentation](https://github.com/bisq-network/bisq-api-reference/blob/main/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBsq.java) +. + +**Creating And Using Runnable TakeBestPricedOfferToSellBsq Jar** + +To create a runnable jar, see [Creating Runnable Jars](#creating-runnable-jars). To run the jar, edit the conf file for +your use case, and use the java -jar command: + +```asciidoc +$ java -jar take-best-priced-offer-to-sell-bsq.jar \ + --password=xyz \ + --dryrun=false \ + --conf=take-best-priced-offer-to-sell-bsq.conf +``` + +### [Take Best Priced Offer To Buy Xmr](#take-best-priced-offer-to-buy-xmr) + +**Purpose:** Sell your XMR at a higher BTC price + +This bot waits for attractively priced BUY XMR (with BTC) offers to appear, and takes 1 or more of them according to +the bot's configuration. The bot will consider only those offers created with the same payment method associated with +your bot's configured payment account id. + +**Warning** + +Take special care to not run the Bisq API daemon and the desktop application at the same time on the same host. + +**Use Cases, Usage and Configuration** + +This information is found in +the [source code's Java class level documentation](https://github.com/bisq-network/bisq-api-reference/blob/main/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyXmr.java) +. + +**Creating And Using Runnable TakeBestPricedOfferToBuyXmr Jar** + +To create a runnable jar, see [Creating Runnable Jars](#creating-runnable-jars). To run the jar, edit the conf file for +your use case, and use the java -jar command: + +```asciidoc +$ java -jar take-best-priced-offer-to-buy-xmr.jar \ + --password=xyz \ + --dryrun=false \ + --conf=take-best-priced-offer-to-buy-xmr.conf +``` + +### [Take Best Priced Offer To Sell Xmr](#take-best-priced-offer-to-sell-xmr) + +**Purpose:** Buy XMR at a lower BTC price + +This bot waits for attractively priced SELL XMR (for BTC) offers to appear, and takes 1 or more of them according to +the bot's configuration. The bot will consider only those offers created with the same payment method associated with +your bot's configured payment account id. + +**Warning** + +Take special care to not run the Bisq API daemon and the desktop application at the same time on the same host. + +**Use Cases, Usage and Configuration** + +This information is found in +the [source code's Java class level documentation](https://github.com/bisq-network/bisq-api-reference/blob/main/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellXmr.java) +. + +**Creating And Using Runnable TakeBestPricedOfferToSellXmr Jar** + +To create a runnable jar, see [Creating Runnable Jars](#creating-runnable-jars). To run the jar, edit the conf file for +your use case, and use the java -jar command: + +```asciidoc +$ java -jar take-best-priced-offer-to-sell-xmr.jar \ + --password=xyz \ + --dryrun=false \ + --conf=take-best-priced-offer-to-sell-xmr.conf +``` + +### [Creating Runnable Jars](#creating-runnable-jars) + +You can create runnable jars for these bots and run them in a terminal. After building the java-examples project, run +the script **java-examples/scripts/create-bot-jars.sh**. You can run the jar from the +**java-examples/scripts/java-examples** folder created by the script, or copy that folder where you like and run it from +there. + +Here are the steps to create runnable bot jars. + +```asciidoc +# Build the java-examples project. +$ cd java-examples +$ ./gradlew clean build + +# Build the runnable bot jars. Each jar contains one class, with its dependencies defined in its MANIFEST.MF. +$ scripts/create-bot-jars.sh +``` + +Each jar has its own conf file, generated from the bot source code's properties file. For example, + +* take-best-priced-offer-to-buy-btc.jar +* take-best-priced-offer-to-buy-btc.conf + +are created from + +* TakeBestPricedOfferToBuyBtc.java +* TakeBestPricedOfferToBuyBtc.properties + +To run it, edit the conf file for your use case and run a java -jar command: + +```asciidoc +$ java -jar take-best-priced-offer-to-sell-btc.jar \ + --password=xyz \ + --dryrun=false \ + --conf=take-best-priced-offer-to-sell-btc.conf +``` + +You can rename a conf file as you like, and save several copies for specific use cases. + +## [Gradle Build File](#gradle-build-file) + +This +project's [Gradle build file](https://github.com/bisq-network/bisq-api-reference/blob/main/java-examples/build.gradle), +shows how to generate the necessary protobuf classes from the Bisq .proto files. + + diff --git a/java-examples/scripts/create-bot-jars.sh b/java-examples/scripts/create-bot-jars.sh index 183ab93..6d1eb95 100755 --- a/java-examples/scripts/create-bot-jars.sh +++ b/java-examples/scripts/create-bot-jars.sh @@ -45,6 +45,9 @@ extractdistribution ./create-runnable-jar.sh "$GRADLE_DIST_NAME" bisq.bots.TakeBestPricedOfferToBuyBtc ./create-runnable-jar.sh "$GRADLE_DIST_NAME" bisq.bots.TakeBestPricedOfferToSellBtc +./create-runnable-jar.sh "$GRADLE_DIST_NAME" bisq.bots.TakeBestPricedOfferToBuyXmr +./create-runnable-jar.sh "$GRADLE_DIST_NAME" bisq.bots.TakeBestPricedOfferToSellXmr + ./create-runnable-jar.sh "$GRADLE_DIST_NAME" bisq.bots.TakeBestPricedOfferToBuyBsq ./create-runnable-jar.sh "$GRADLE_DIST_NAME" bisq.bots.TakeBestPricedOfferToSellBsq diff --git a/java-examples/scripts/create-runnable-jar.sh b/java-examples/scripts/create-runnable-jar.sh index 10dc197..1538494 100755 --- a/java-examples/scripts/create-runnable-jar.sh +++ b/java-examples/scripts/create-runnable-jar.sh @@ -84,15 +84,15 @@ MAINCLASS_FILE_PATH=$(getmainclassfilepath "$FULLY_QUALIFIED_CLASSNAME") # Extract the Main-Class from the distribution jar, to the current working directory. jar xfv "lib/$GRADLE_DIST_NAME.jar" "$MAINCLASS_FILE_PATH" "$SIMPLE_CLASSNAME.properties" -echo "Extracted one class:" -ls -l bisq/bots/pazza +echo "Extracted $SIMPLE_CLASSNAME.class:" +ls -l "bisq/bots/$SIMPLE_CLASSNAME.class" mv "$SIMPLE_CLASSNAME.properties" "$JAR_BASENAME.conf" -echo "Extracted one properties file and renamed it $JAR_BASENAME.conf" -ls -l *.conf +echo "Extracted $SIMPLE_CLASSNAME.properties and renamed it $JAR_BASENAME.conf" +ls -l "$JAR_BASENAME.conf" # Now it can be added to the empty jar with the correct path. jar uf "$JAR_BASENAME.jar" "$MAINCLASS_FILE_PATH" -# Remove bisq (bisq/bots/junk). +# Remove workarea. rm -rf bisq echo "Runnable $JAR_BASENAME.jar is ready to use." diff --git a/java-examples/src/main/java/bisq/bots/AbstractBot.java b/java-examples/src/main/java/bisq/bots/AbstractBot.java index f6ba7c9..c9279c3 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. @@ -327,6 +345,19 @@ public abstract class AbstractBot { } } + /** + * Return true if gRPC StatusRuntimeException indicates wallet is locked. Sometimes this is a trivial error. + * + * @param grpcException a StatusRuntimeException + * @return true if gRPC StatusRuntimeException indicates wallet is locked + */ + protected boolean walletIsLocked(StatusRuntimeException grpcException) { + var errorMsg = toCleanErrorMessage.apply(grpcException).toLowerCase(); + return (exceptionHasStatus.test(grpcException, FAILED_PRECONDITION) + && errorMsg.contains("wallet") + && errorMsg.contains("locked")); + } + /** * Attempt to unlock the wallet for 1 second using the given password, and shut down the bot if the * password check fails for any reason. @@ -396,23 +427,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."); } /** @@ -526,6 +588,21 @@ public abstract class AbstractBot { BotUtils.printTradesSummary(category, trades); } + /** + * Print the completed trades since midnight today, if the wallet is unlocked, else log an error message. + */ + protected void printTradesSummaryForTodayIfWalletIsUnlocked() { + try { + printTradesSummaryForToday(CLOSED); + } catch (StatusRuntimeException grpcException) { + if (walletIsLocked(grpcException)) { + log.error("Cannot show today's trades while API daemon's wallet is locked."); + } else { + throw grpcException; + } + } + } + /** * Print list of today's trade summaries to stdout. * @@ -536,7 +613,12 @@ public abstract class AbstractBot { var trades = getTrades(category).stream() .filter(t -> t.getDate() >= midnightToday) .collect(Collectors.toList()); - BotUtils.printTradesSummary(category, trades); + if (trades.isEmpty()) { + log.info("No trades have been completed today."); + } else { + log.info("Here are today's completed trades:"); + BotUtils.printTradesSummary(category, trades); + } } /** @@ -632,9 +714,8 @@ 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.isEmpty()) { + printOffersSummary(offersTakenDuringDryRun); } } @@ -642,9 +723,7 @@ public abstract class AbstractBot { * Add offer to list of taken offers -- for dry runs only. */ protected void addToOffersTaken(OfferInfo offer) { - offersTaken.add(offer); - printOfferSummary(offer); - log.info("Did not actually take that offer during this simulation."); + offersTakenDuringDryRun.add(offer); } /** @@ -666,6 +745,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 +794,78 @@ public abstract class AbstractBot { exit(1); } + /** + * Print the day's completed trades since midnight today, and number of offers taken during the bot session, then, + *

+ * If numOffersTaken >= maxTakeOffers, shut down the bot. + *

+ * If numOffersTaken < maxTakeOffers, log the number of offers taken so far during the bot run. + * + * @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) { + printTradesSummaryForTodayIfWalletIsUnlocked(); + + log.info("You {}have taken {} swap offer(s) during this bot's {}", + isDryRun ? "would " : "", + numOffersTaken, + isDryRun ? "dryrun:" : "session."); + if (isDryRun) { + printDryRunProgress(); + } + + if (numOffersTaken >= maxTakeOffers) { + isShutdown = true; + log.info("Shutting down API bot after executing {} BSQ swaps.", numOffersTaken); + exit(0); + } + } + + /** + * Print the day's completed trades since midnight today, and number of offers taken during the bot session, 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) { + printTradesSummaryForTodayIfWalletIsUnlocked(); + + log.info("You {} have taken {} offer(s) during this bot's {}", + isDryRun ? "would" : "", + numOffersTaken, + isDryRun ? "dryrun:" : "session."); + if (isDryRun) { + printDryRunProgress(); + } + + 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); + } + } + /** * Returns Properties object for this bot. * @@ -705,7 +888,11 @@ public abstract class AbstractBot { try { var defaultFilename = defaultPropertiesFilename.get(); properties.load(this.getClass().getClassLoader().getResourceAsStream(defaultFilename)); - log.info("Internal configuration loaded from {}.", defaultFilename); + if (this instanceof RegtestTradePaymentSimulator) { + log.debug("Internal configuration loaded from {}.", defaultFilename); + } else { + log.info("Internal configuration loaded from {}.", defaultFilename); + } } catch (Exception ex) { throw new IllegalStateException(ex); } @@ -726,7 +913,11 @@ public abstract class AbstractBot { Properties properties = new java.util.Properties(); try { properties.load(is); - log.info("External configuration loaded from {}.", confFile.getAbsolutePath()); + if (this instanceof RegtestTradePaymentSimulator) { + log.debug("External configuration loaded from {}.", confFile.getAbsolutePath()); + } else { + log.info("External configuration loaded from {}.", confFile.getAbsolutePath()); + } return properties; } catch (FileNotFoundException ignored) { // Cannot happen here. Ignore FileNotFoundException because confFile.exists() == true. diff --git a/java-examples/src/main/java/bisq/bots/BotUtils.java b/java-examples/src/main/java/bisq/bots/BotUtils.java index 3582fae..2db1f38 100644 --- a/java-examples/src/main/java/bisq/bots/BotUtils.java +++ b/java-examples/src/main/java/bisq/bots/BotUtils.java @@ -42,6 +42,7 @@ import static java.lang.String.format; import static java.lang.System.*; import static java.math.BigDecimal.ZERO; import static java.math.RoundingMode.HALF_UP; +import static java.util.Objects.requireNonNull; /** * Convenience methods and functions not depending on a bot's state nor the need to send requests to the API daemon. @@ -152,6 +153,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 +205,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 +283,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. @@ -388,6 +408,7 @@ public class BotUtils { * @param offer printed offer */ public static void printOfferSummary(OfferInfo offer) { + requireNonNull(offer, "OfferInfo offer param cannot be null."); new TableBuilder(OFFER_TBL, offer).build().print(out); } @@ -397,7 +418,12 @@ public class BotUtils { * @param offers printed offer list */ public static void printOffersSummary(List offers) { - new TableBuilder(OFFER_TBL, offers).build().print(out); + requireNonNull(offers, "List offers param cannot be null."); + if (offers.isEmpty()) { + log.info("No offers to print."); + } else { + new TableBuilder(OFFER_TBL, offers).build().print(out); + } } /** @@ -406,6 +432,7 @@ public class BotUtils { * @param trade printed trade */ public static void printTradeSummary(TradeInfo trade) { + requireNonNull(trade, "TradeInfo trade param cannot be null."); new TableBuilder(TRADE_DETAIL_TBL, trade).build().print(out); } @@ -416,10 +443,15 @@ public class BotUtils { * @param trades list of trades */ public static void printTradesSummary(GetTradesRequest.Category category, List trades) { - switch (category) { - case CLOSED -> new TableBuilder(CLOSED_TRADES_TBL, trades).build().print(out); - case FAILED -> new TableBuilder(FAILED_TRADES_TBL, trades).build().print(out); - default -> new TableBuilder(OPEN_TRADES_TBL, trades).build().print(out); + requireNonNull(trades, "List trades param cannot be null."); + if (trades.isEmpty()) { + log.info("No trades to print."); + } else { + switch (category) { + case CLOSED -> new TableBuilder(CLOSED_TRADES_TBL, trades).build().print(out); + case FAILED -> new TableBuilder(FAILED_TRADES_TBL, trades).build().print(out); + default -> new TableBuilder(OPEN_TRADES_TBL, trades).build().print(out); + } } } @@ -429,6 +461,7 @@ public class BotUtils { * @param paymentAccount the printed PaymentAccount */ public static void printPaymentAccountSummary(PaymentAccount paymentAccount) { + requireNonNull(paymentAccount, "PaymentAccount paymentAccount param cannot be null."); new TableBuilder(PAYMENT_ACCOUNT_TBL, paymentAccount).build().print(out); } @@ -519,6 +552,44 @@ public class BotUtils { log.warn(BANNER); } + /** + * Log a CLI gettrade command for a simulated trading peer. + * + * @param log calling bot's logger + * @param tradingPeerApiPassword trading peer's CLI --password param value + * @param tradingPeerApiPort trading peer's CLI --port param value + * @param tradeId trade's unique identifier (cannot be short-id) + */ + public static void printCliGetTradeCommand(Logger log, + String tradingPeerApiPassword, + int tradingPeerApiPort, + String tradeId) { + log.warn(BANNER); + log.warn("Trading peer can view a trade with a gettrade CLI command:"); + log.warn("./bisq-cli --password={} --port={} gettrade --trade-id={}", + tradingPeerApiPassword, + tradingPeerApiPort, + tradeId); + log.warn(BANNER); + } + + /** + * Log 1 or more CLI commands for a simulated trading peer. + * Commands need to be separated by newlines to be legible. + * + * @param log calling bot's logger + * @param description description of CLI commands + * @param commands CLI commands separated by newlines. + */ + public static void printCliCommands(Logger log, + String description, + String commands) { + log.warn(BANNER); + log.warn(description); + log.warn(commands); + log.warn(BANNER); + } + /** * Run a bash script to count down the given number of seconds, printing each character of output from stdout. *

diff --git a/java-examples/src/main/java/bisq/bots/Config.java b/java-examples/src/main/java/bisq/bots/Config.java index 7438073..64a1c97 100644 --- a/java-examples/src/main/java/bisq/bots/Config.java +++ b/java-examples/src/main/java/bisq/bots/Config.java @@ -17,6 +17,7 @@ package bisq.bots; +import joptsimple.BuiltinHelpFormatter; import joptsimple.OptionParser; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -63,12 +64,12 @@ public class Config { .withRequiredArg(); var confOpt = parser.accepts("conf", "Bot configuration file (required)") .withRequiredArg(); - var dryRunOpt = parser.accepts("dryrun", "Pretend to take an offer (default=false)") + var dryRunOpt = parser.accepts("dryrun", "Pretend to take an offer") .withRequiredArg() .ofType(boolean.class) .defaultsTo(FALSE); var simulateRegtestPaymentStepsOpt = - parser.accepts("simulate-regtest-payment", "Simulate regtest payment steps (default=false)") + parser.accepts("simulate-regtest-payment", "Simulate regtest payment steps") .withOptionalArg() .ofType(boolean.class) .defaultsTo(FALSE); @@ -126,10 +127,23 @@ public class Config { stream.println(); stream.println("Usage: ScriptName [options]"); stream.println(); + parser.formatHelpWith(new HelpFormatter(90, 2)); parser.printHelpOn(stream); stream.println(); } catch (IOException ex) { ex.printStackTrace(stream); } } + + private static class HelpFormatter extends BuiltinHelpFormatter { + /** + * Makes a formatter with a given overall row width and column separator width. + * + * @param desiredOverallWidth how many characters wide to make the overall help display + * @param desiredColumnSeparatorWidth how many characters wide to make the separation between option column and + */ + public HelpFormatter(int desiredOverallWidth, int desiredColumnSeparatorWidth) { + super(desiredOverallWidth, desiredColumnSeparatorWidth); + } + } } diff --git a/java-examples/src/main/java/bisq/bots/GrpcStubs.java b/java-examples/src/main/java/bisq/bots/GrpcStubs.java index 2f6d101..73b19b7 100644 --- a/java-examples/src/main/java/bisq/bots/GrpcStubs.java +++ b/java-examples/src/main/java/bisq/bots/GrpcStubs.java @@ -63,9 +63,9 @@ final class GrpcStubs { public void close() { try { if (!channel.isShutdown()) { - log.info("Shutting down bot's grpc channel."); + log.debug("Shutting down bot's grpc channel."); channel.shutdown().awaitTermination(1, SECONDS); - log.info("Bot channel shutdown complete."); + log.debug("Bot channel shutdown complete."); } } catch (InterruptedException ex) { throw new IllegalStateException(ex); diff --git a/java-examples/src/main/java/bisq/bots/RegtestTradePaymentSimulator.java b/java-examples/src/main/java/bisq/bots/RegtestTradePaymentSimulator.java index f117ea9..a57eac3 100644 --- a/java-examples/src/main/java/bisq/bots/RegtestTradePaymentSimulator.java +++ b/java-examples/src/main/java/bisq/bots/RegtestTradePaymentSimulator.java @@ -24,7 +24,6 @@ import protobuf.PaymentAccount; import java.util.Properties; import static bisq.bots.BotUtils.*; -import static bisq.proto.grpc.GetTradesRequest.Category.CLOSED; import static io.grpc.Status.Code.PERMISSION_DENIED; /** @@ -106,18 +105,13 @@ public class RegtestTradePaymentSimulator extends AbstractBot { closeTrade(tradeId); log.info("You closed the trade here in the bot (mandatory, to move trades to history list)."); - log.warn("##############################################################################"); - log.warn("Bob closes trade in the CLI (mandatory, to move trades to history list):"); - String copyPasteCliCommands = "./bisq-cli --password=xyz --port=9999 closetrade --trade-id=" + trade.getTradeId() + String cliCommandDescription = "Trading peer inspects and closes trade in the CLI (mandatory, to move trades to history list):"; + String copyPasteCliCommands = "./bisq-cli --password=xyz --port=9999 gettrade --trade-id=" + trade.getTradeId() + + "\n" + "./bisq-cli --password=xyz --port=9999 closetrade --trade-id=" + trade.getTradeId() + "\n" + "./bisq-cli --password=xyz --port=9999 gettrades --category=closed"; - log.warn(copyPasteCliCommands); - log.warn("##############################################################################"); + printCliCommands(log, cliCommandDescription, copyPasteCliCommands); - sleep(pollingInterval); - log.info("Trade is completed. Here are today's completed trades:"); - printTradesSummaryForToday(CLOSED); - - log.info("Closing {}'s gRPC channel.", this.getClass().getSimpleName()); + log.debug("Closing {}'s gRPC channel.", this.getClass().getSimpleName()); super.grpcStubs.close(); } diff --git a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java index 57c9ed5..ac862bc 100644 --- a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBsq.java @@ -25,48 +25,89 @@ 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. + * This bot's general use case is to sell your BSQ for BTC at a high BTC price. It periodically checks the + * Buy BSQ (Sell BTC) market, and takes a configured maximum number of offers to buy BSQ from you according to criteria + * you define in the bot's configuration file: TakeBestPricedOfferToBuyBsq.properties (located in project's + * src/main/resources directory). You will need to replace the default values in the configuration file for your + * use cases. + *


+ * After the maximum number of BSQ swap offers have been taken (good to start with 1), the bot will shut down. The API + * daemon will not be shut down because swaps do not require additional payment related steps taken outside Bisq, or + * in the GUI. *

- * I'm taking liberties with the classname by not naming it TakeBestPricedOfferToSellBtcForBsq. + * Here is one possible use case: + *

+ *      Take 1 BSQ swap offer to buy BSQ with BTC, priced no lower than 0.50% above the 30-day average BSQ price if:
+ *
+ *          the offer's BTC amount is between 0.10 and 0.25 BTC
+ *          the offer maker is one of two preferred trading peers
+ *          the current transaction mining fee rate is less than or equal 20 sats / byte
+ *
+ *  The bot configurations for these rules are set in TakeBestPricedOfferToBuyBsq.properties as follows:
+ *
+ *          maxTakeOffers=1
+ *          minMarketPriceMargin=0.50
+ *          minAmount=0.10
+ *          maxAmount=0.25
+ *          preferredTradingPeers=preferred-address-1.onion:9999,preferred-address-2.onion:9999
+ *          maxTxFeeRate=20
+ * 
+ * Usage + *


+ * You must encrypt your wallet password before running this bot. If it is not already encrypted, you can use the CLI: + *

+ *     $ ./bisq-cli --password=xyz --port=9998 setwalletpassword --wallet-password="be careful"
+ * 
+ * There are some {@link bisq.bots.Config program options} common to all the Java bot examples, passed on the command + * line. The only one you must provide (no default value) is your API daemon's password option: + * `--password `. The bot will prompt you for your wallet-password in the console. + *


+ * You can pass the '--dryrun=true' option to the program to see what offers your bot would take with a given + * configuration. This will help you avoid taking offers by mistake. + *

+ *     TakeBestPricedOfferToBuyBsq  --password=api-password --port=api-port [--dryrun=true|false]
+ * 
+ *

+ * The '--simulate-regtest-payment=true' option is ignored by this bot. Taking a swap triggers execution of the swap. + * + * @see bisq.bots.Config.java */ @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 +115,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")) @@ -104,8 +144,9 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { continue; } - // Get all available and takeable offers, sorted by price descending. - var offers = getOffers(SELL.name(), currencyCode).stream() + // Get all available sell BTC for BSQ offers, sorted by price descending. + // The list contains only fixed-priced offers. + var offers = getOffers(SELL.name(), CURRENCY_CODE).stream() .filter(o -> !isAlreadyTaken.test(o)) .toList(); @@ -130,7 +171,6 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { takeCriteria.printOfferAgainstCriteria(highestPricedOffer); }); - printDryRunProgress(); runCountdown(log, pollingInterval); pingDaemon(startTime); } @@ -139,17 +179,21 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { log.info("Will attempt to take offer '{}'.", offer.getId()); takeCriteria.printOfferAgainstCriteria(offer); + + // An encrypted wallet must be unlocked before calling takeoffer and gettrade(s). + // Unlock the wallet for 5 minutes. If the wallet is already unlocked, this request + // will override the timeout of the previous unlock request. + try { + unlockWallet(walletPassword, 300); + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException, pollingInterval); + } + if (isDryRun) { addToOffersTaken(offer); numOffersTaken++; - maybeShutdownAfterSuccessfulSwap(); } else { - // An encrypted wallet must be unlocked before calling takeoffer and gettrade. - // Unlock the wallet for 10 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 Swap Execution"); printBSQBalances("BSQ Balances Before Swap Execution"); @@ -160,13 +204,13 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { printBSQBalances("BSQ Balances After Swap Execution"); numOffersTaken++; - maybeShutdownAfterSuccessfulSwap(); } catch (NonFatalException nonFatalException) { - handleNonFatalException(nonFatalException); + handleNonFatalException(nonFatalException, pollingInterval); } catch (StatusRuntimeException fatalException) { - handleFatalException(fatalException); + handleFatalBsqSwapException(fatalException); } } + maybeShutdownAfterSuccessfulSwap(numOffersTaken, maxTakeOffers); } private void printBotConfiguration() { @@ -174,12 +218,13 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion()); var network = getNetwork(); configsByLabel.put("BTC Network:", network); + configsByLabel.put("Dry Run?", isDryRun ? "YES" : "NO"); var isMainnet = network.equalsIgnoreCase("mainnet"); var mainnet30DayAvgBsqPrice = isMainnet ? get30DayAvgBsqPriceInBtc() : null; 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 +245,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 +288,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); } } @@ -343,13 +334,13 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot { iHavePreferredTradingPeers.get() ? 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); + var fixedPriceLabel = format("Is offer fixed-price (%s) >= bot's minimum price of (%s)?", + 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..476af30 100644 --- a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBtc.java +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyBtc.java @@ -25,58 +25,64 @@ 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 - * (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. + * This bot's general use case is to sell your BTC for fiat at a high BTC price. It periodically checks the + * Buy BTC market, and takes a configured maximum number of offers to buy BTC from you according to criteria you + * define in the bot's configuration file: TakeBestPricedOfferToBuyBtc.properties (located in project's + * src/main/resources directory). You will need to replace the default values in the configuration file for your + * use cases. + *


+ * After the maximum number of offers have been taken (good to start with 1), the bot will shut down the API daemon, + * then itself. You have to confirm the offer maker's fiat payment(s) outside Bisq, then complete the trade(s) in + * the Bisq Desktop application. *

- * The benefit this bot provides is freeing up the user time spent watching the offer book in the UI, waiting for the - * right offer to take. This bot increases the chance of beating the other nodes at taking the offer. - *

- * The disadvantage is that if the user takes offers with the API, she must complete the trades with the desktop UI. - * This problem is due to the inability of the API to fully automate every step of the trading protocol. Sending fiat - * payments, and confirming their receipt, are manual activities performed outside the Bisq daemon and desktop UI. - * Also, the API and the desktop UI cannot run at the same time. Care must be taken to shut down one before starting - * the other. - *

- * The criteria for determining which offers to take are defined in the bot's configuration file - * TakeBestPricedOfferToBuyBtc.properties (located in project's src/main/resources directory). The individual - * configurations are commented in the existing TakeBestPricedOfferToBuyBtc.properties, which should be used as a - * template for your own use case. - *

- * One possible use case for this bot is sell BTC for GBP: + * Here is one possible use case: *

- *      Take a "Faster Payment (Santander)" offer to buy BTC with GBP at or above current market price if:
- *          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.
+ *      Take 3 "Faster Payment" offers to buy BTC with GBP, priced no lower than 2.00% above the current market
+ *      price if:
+ *
+ *          the offer's BTC amount is between 0.10 and 0.25 BTC
+ *          the offer maker is one of two preferred trading peers
+ *          the current transaction mining fee rate is less than or equal 20 sats / byte
+ *
+ *  The bot configurations for these rules are set in TakeBestPricedOfferToBuyBtc.properties as follows:
+ *
+ *          maxTakeOffers=3
+ *          minMarketPriceMargin=2.00
+ *          minAmount=0.10
+ *          maxAmount=0.25
+ *          preferredTradingPeers=preferred-address-1.onion:9999,preferred-address-2.onion:9999
+ *          maxTxFeeRate=20
  * 
- *

- * 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.) + * Usage + *


+ * You must encrypt your wallet password before running this bot. If it is not already encrypted, you can use the CLI: *

- *      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.
+ *     $ ./bisq-cli --password=xyz --port=9998 setwalletpassword --wallet-password="be careful"
  * 
- *

+ * There are some {@link bisq.bots.Config program options} common to all the Java bot examples, passed on the command + * line. The only one you must provide (no default value) is your API daemon's password option: + * `--password `. The bot will prompt you for your wallet-password in the console. + *


+ * You can pass the '--dryrun=true' option to the program to see what offers your bot would take with a given + * configuration. This will help you avoid taking offers by mistake. *

- * Usage:  TakeBestPricedOfferToBuyBtc  --password=api-password --port=api-port \
- *                          [--conf=take-best-priced-offer-to-buy-btc.conf] \
- *                          [--dryrun=true|false]
- *                          [--simulate-regtest-payment=true|false]
+ *     TakeBestPricedOfferToBuyBtc  --password=api-password --port=api-port [--dryrun=true|false]
  * 
+ * If your API daemon is running on a local regtest network (with a trading peer), you can pass the + * '--simulate-regtest-payment=true' option to the program to simulate the full trade protocol. The bot will print + * your regtest trading peer's CLI commands in the console, for you to copy/paste into another terminal. + *
+ *     TakeBestPricedOfferToBuyBtc  --password=api-password --port=api-port [--simulate-regtest-payment=true|false]
+ * 
+ * + * @see bisq.bots.Config.java */ @Slf4j @Getter @@ -90,21 +96,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 +150,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(); @@ -174,7 +177,6 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot { takeCriteria.printOfferAgainstCriteria(highestPricedOffer); }); - printDryRunProgress(); runCountdown(log, pollingInterval); pingDaemon(startTime); } @@ -188,16 +190,21 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot { private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { log.info("Will attempt to take offer '{}'.", offer.getId()); takeCriteria.printOfferAgainstCriteria(offer); + + // An encrypted wallet must be unlocked before calling takeoffer and gettrade(s). + // Unlock the wallet for 5 minutes. If the wallet is already unlocked, this request + // will override the timeout of the previous unlock request. + try { + unlockWallet(walletPassword, 300); + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException, pollingInterval); + } + if (isDryRun) { addToOffersTaken(offer); numOffersTaken++; - maybeShutdownAfterSuccessfulTradeCreation(); } 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); @@ -212,79 +219,13 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot { printBTCBalances("BTC Balances After Simulated Trade Completion"); } numOffersTaken++; - maybeShutdownAfterSuccessfulTradeCreation(); } 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."); + maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers); } /** @@ -297,18 +238,13 @@ 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()); var network = getNetwork(); configsByLabel.put("BTC Network:", network); + configsByLabel.put("Dry Run?", isDryRun ? "YES" : "NO"); + configsByLabel.put("Simulate Regtest Trade?", canSimulatePaymentSteps ? "YES" : "NO"); configsByLabel.put("My Payment Account:", ""); configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); @@ -338,17 +274,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 +298,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 +347,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..168274b --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToBuyXmr.java @@ -0,0 +1,369 @@ +/* + * 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; + +/** + * This bot's general use case is to sell your XMR for BTC at a high BTC price. It periodically checks the + * Buy XMR (Sell BTC) market, and takes a configured maximum number of offers to buy XMR from you according to criteria + * you define in the bot's configuration file: TakeBestPricedOfferToBuyXmr.properties (located in project's + * src/main/resources directory). You will need to replace the default values in the configuration file for your + * use cases. + *


+ * After the maximum number of offers have been taken (good to start with 1), the bot will shut down the API daemon, + * then itself. You have to send XMR payment to the offer maker(s) outside Bisq, then complete the trade(s) in the + * Bisq Desktop application. + *

+ * Here is one possible use case: + *

+ *  Take 2 offers to buy your XMR for BTC, priced no lower than -1.50% above or below current market price if:
+ *
+ *          the offer's BTC amount is between 0.50 and 1.00 BTC
+ *          the offer maker is one of two preferred trading peers
+ *          the current transaction mining fee rate is less than or equal 20 sats / byte
+ *
+ *  The bot configurations for these rules are set in TakeBestPricedOfferToBuyXmr.properties as follows:
+ *
+ *          maxTakeOffers=2
+ *          minMarketPriceMargin=-1.50
+ *          minAmount=0.50
+ *          maxAmount=1.00
+ *          preferredTradingPeers=preferred-address-1.onion:9999,preferred-address-2.onion:9999
+ *          maxTxFeeRate=20
+ * 
+ * Usage + *


+ * You must encrypt your wallet password before running this bot. If it is not already encrypted, you can use the CLI: + *

+ *     $ ./bisq-cli --password=xyz --port=9998 setwalletpassword --wallet-password="be careful"
+ * 
+ * There are some {@link bisq.bots.Config program options} common to all the Java bot examples, passed on the command + * line. The only one you must provide (no default value) is your API daemon's password option: + * `--password `. The bot will prompt you for your wallet-password in the console. + *


+ * You can pass the '--dryrun=true' option to the program to see what offers your bot would take with a given + * configuration. This will help you avoid taking offers by mistake. + *

+ *     TakeBestPricedOfferToBuyXmr  --password=api-password --port=api-port [--dryrun=true|false]
+ * 
+ * If your API daemon is running on a local regtest network (with a trading peer), you can pass the + * '--simulate-regtest-payment=true' option to the program to simulate the full trade protocol. The bot will print + * your regtest trading peer's CLI commands in the console, for you to copy/paste into another terminal. + *
+ *     TakeBestPricedOfferToBuyXmr  --password=api-password --port=api-port [--simulate-regtest-payment=true|false]
+ * 
+ * + * @see bisq.bots.Config.java + */ +@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); + }); + + 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); + + // An encrypted wallet must be unlocked before calling takeoffer and gettrade(s). + // Unlock the wallet for 5 minutes. If the wallet is already unlocked, this request + // will override the timeout of the previous unlock request. + try { + unlockWallet(walletPassword, 300); + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException, pollingInterval); + } + + if (isDryRun) { + addToOffersTaken(offer); + numOffersTaken++; + } else { + try { + 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++; + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException, pollingInterval); + } catch (StatusRuntimeException fatalException) { + shutdownAfterTakeOfferFailure(fatalException); + } + } + maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers); + } + + /** + * 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("Dry Run?", isDryRun ? "YES" : "NO"); + configsByLabel.put("Simulate Regtest Trade?", canSimulatePaymentSteps ? "YES" : "NO"); + 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 margin based price (%s) >= bot's target price (%s)?", + offer.getPrice() + " BTC", + targetPrice + " BTC"); + filterResultsByLabel.put(marginPriceLabel, isMarginGEMinMarketPriceMargin.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..e30d76a 100644 --- a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBsq.java +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBsq.java @@ -25,48 +25,89 @@ 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. + * This bot's general use case is to buy BSQ with BTC at a low BTC price. It periodically checks the + * Sell BSQ (Buy BTC) market, and takes a configured maximum number of offers to buy BSQ from you according to criteria + * you define in the bot's configuration file: TakeBestPricedOfferToSellBsq.properties (located in project's + * src/main/resources directory). You will need to replace the default values in the configuration file for your + * use cases. + *


+ * After the maximum number of BSQ swap offers have been taken (good to start with 1), the bot will shut down. The API + * daemon will not be shut down because swaps do not require additional payment related steps taken outside Bisq, or + * in the GUI. *

- * I'm taking liberties with the classname by not naming it TakeBestPricedOfferToBuyBtcForBsq. + * Here is one possible use case: + *

+ *      Take 5 BSQ swap offers to sell BSQ for BTC, priced no higher than -1.00% below the 30-day average BSQ price if:
+ *
+ *          the offer's BTC amount is between 0.10 and 0.25 BTC
+ *          the offer maker is one of two preferred trading peers
+ *          the current transaction mining fee rate is less than or equal 25 sats / byte
+ *
+ *  The bot configurations for these rules are set in TakeBestPricedOfferToSellBsq.properties as follows:
+ *
+ *          maxTakeOffers=5
+ *          maxMarketPriceMargin=-1.00
+ *          minAmount=0.10
+ *          maxAmount=0.25
+ *          preferredTradingPeers=preferred-address-1.onion:9999,preferred-address-2.onion:9999
+ *          maxTxFeeRate=25
+ * 
+ * Usage + *


+ * You must encrypt your wallet password before running this bot. If it is not already encrypted, you can use the CLI: + *

+ *     $ ./bisq-cli --password=xyz --port=9998 setwalletpassword --wallet-password="be careful"
+ * 
+ * There are some {@link bisq.bots.Config program options} common to all the Java bot examples, passed on the command + * line. The only one you must provide (no default value) is your API daemon's password option: + * `--password `. The bot will prompt you for your wallet-password in the console. + *


+ * You can pass the '--dryrun=true' option to the program to see what offers your bot would take with a given + * configuration. This will help you avoid taking offers by mistake. + *

+ *     TakeBestPricedOfferToBuyBsq  --password=api-password --port=api-port [--dryrun=true|false]
+ * 
+ *

+ * The '--simulate-regtest-payment=true' option is ignored by this bot. Taking a swap triggers execution of the swap. + * + * @see bisq.bots.Config.java */ @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 +115,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")) @@ -104,8 +144,9 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { continue; } - // Get all available and takeable offers, sorted by price ascending. - var offers = getOffers(BUY.name(), currencyCode).stream() + // Get all available buy BTC with BSQ offers, sorted by price ascending. + // The list contains only fixed-priced offers. + var offers = getOffers(BUY.name(), CURRENCY_CODE).stream() .filter(o -> !isAlreadyTaken.test(o)) .toList(); @@ -130,7 +171,6 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { takeCriteria.printOfferAgainstCriteria(cheapestOffer); }); - printDryRunProgress(); runCountdown(log, pollingInterval); pingDaemon(startTime); } @@ -139,17 +179,21 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { log.info("Will attempt to take offer '{}'.", offer.getId()); takeCriteria.printOfferAgainstCriteria(offer); + + // An encrypted wallet must be unlocked before calling takeoffer and gettrade(s). + // Unlock the wallet for 5 minutes. If the wallet is already unlocked, this request + // will override the timeout of the previous unlock request. + try { + unlockWallet(walletPassword, 300); + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException, pollingInterval); + } + if (isDryRun) { addToOffersTaken(offer); numOffersTaken++; - maybeShutdownAfterSuccessfulSwap(); } else { - // An encrypted wallet must be unlocked before calling takeoffer and gettrade. - // Unlock the wallet for 10 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 Swap Execution"); printBSQBalances("BSQ Balances Before Swap Execution"); @@ -160,13 +204,13 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { printBSQBalances("BSQ Balances After Swap Execution"); numOffersTaken++; - maybeShutdownAfterSuccessfulSwap(); } catch (NonFatalException nonFatalException) { - handleNonFatalException(nonFatalException); + handleNonFatalException(nonFatalException, pollingInterval); } catch (StatusRuntimeException fatalException) { - handleFatalException(fatalException); + handleFatalBsqSwapException(fatalException); } } + maybeShutdownAfterSuccessfulSwap(numOffersTaken, maxTakeOffers); } private void printBotConfiguration() { @@ -174,12 +218,13 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion()); var network = getNetwork(); configsByLabel.put("BTC Network:", network); + configsByLabel.put("Dry Run?", isDryRun ? "YES" : "NO"); var isMainnet = network.equalsIgnoreCase("mainnet"); var mainnet30DayAvgBsqPrice = isMainnet ? get30DayAvgBsqPriceInBtc() : null; 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 +245,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 +255,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(); @@ -285,7 +276,6 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { this.targetPrice = calcTargetBsqPrice(maxMarketPriceMargin, avgBsqPrice); } - /** * Returns the lowest priced offer passing the filters, or Optional.empty() if not found. * Max tx fee rate filtering should have passed prior to calling this method. @@ -298,28 +288,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); } } @@ -336,7 +326,7 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { var filterResultsByLabel = new LinkedHashMap(); filterResultsByLabel.put("30-day Avg BSQ trade price:", avgBsqPrice + " BTC"); - filterResultsByLabel.put("Target Price (Min):", targetPrice + " BTC"); + filterResultsByLabel.put("Target Price (Max):", targetPrice + " BTC"); filterResultsByLabel.put("Offer Price:", offer.getPrice() + " BTC"); filterResultsByLabel.put("Offer maker used same payment method?", usesSamePaymentMethod.test(offer, getPaymentAccount())); @@ -344,13 +334,13 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot { iHavePreferredTradingPeers.get() ? 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); + var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's maximum price of (%s)?", + 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..d44c37f 100644 --- a/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBtc.java +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellBtc.java @@ -25,59 +25,64 @@ 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 TakeBestPricedOfferToSellBtc bot waits for attractively priced SELL BTC 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. + * This bot's general use case is to buy BTC with fiat at a low fiat price. It periodically checks the + * Sell BTC market, and takes a configured maximum number of offers to sell BTC to you according to criteria you + * define in the bot's configuration file: TakeBestPricedOfferToSellBtc.properties (located in project's + * src/main/resources directory). You will need to replace the default values in the configuration file for your + * use cases. + *


+ * After the maximum number of offers have been taken (good to start with 1), the bot will shut down the API daemon, + * then itself. You have to send fiat payment(s) to the offer maker(s) outside Bisq, then complete the trade(s) in + * the Bisq Desktop application. *

- * The benefit this bot provides is freeing up the user time spent watching the offer book in the UI, waiting for the - * right offer to take. Low-priced offers are taken relatively quickly; this bot increases the chance of beating - * the other nodes at taking the offer. - *

- * The disadvantage is that if the user takes offers with the API, she must complete the trades with the desktop UI. - * This problem is due to the inability of the API to fully automate every step of the trading protocol. Sending fiat - * payments, and confirming their receipt, are manual activities performed outside the Bisq daemon and desktop UI. - * Also, the API and the desktop UI cannot run at the same time. Care must be taken to shut down one before starting - * the other. - *

- * The criteria for determining which offers to take are defined in the bot's configuration file - * TakeBestPricedOfferToSellBtc.properties (located in project's src/main/resources directory). The individual - * configurations are commented in the existing TakeBestPricedOfferToSellBtc.properties, which should be used as a - * template for your own use case. - *

- * One possible use case for this bot is buy BTC with GBP: + * Here is one possible use case: *

- *      Take a "Faster Payment (Santander)" offer to sell BTC for GBP at or below current market price if:
- *          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.
+ *      Take 4 "Faster Payment" offers to sell BTC for GBP, priced no higher than 1.00% above the current market
+ *      price if:
+ *
+ *          the offer's BTC amount is between 0.10 and 0.25 BTC
+ *          the offer maker is one of two preferred trading peers
+ *          the current transaction mining fee rate is less than or equal 20 sats / byte
+ *
+ *  The bot configurations for these rules are set in TakeBestPricedOfferToSellBtc.properties as follows:
+ *
+ *          maxTakeOffers=4
+ *          minMarketPriceMargin=1.00
+ *          minAmount=0.10
+ *          maxAmount=0.25
+ *          preferredTradingPeers=preferred-address-1.onion:9999,preferred-address-2.onion:9999
+ *          maxTxFeeRate=20
  * 
- *

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


+ * You must encrypt your wallet password before running this bot. If it is not already encrypted, you can use the CLI: *

- *      Take an offer to sell BTC for XMR at 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.
+ *     $ ./bisq-cli --password=xyz --port=9998 setwalletpassword --wallet-password="be careful"
  * 
- *

+ * There are some {@link bisq.bots.Config program options} common to all the Java bot examples, passed on the command + * line. The only one you must provide (no default value) is your API daemon's password option: + * `--password `. The bot will prompt you for your wallet-password in the console. + *


+ * You can pass the '--dryrun=true' option to the program to see what offers your bot would take with a given + * configuration. This will help you avoid taking offers by mistake. *

- * Usage:  TakeBestPricedOfferToSellBtc  --password=api-password --port=api-port \
- *                          [--conf=take-best-priced-offer-to-sell-btc.conf] \
- *                          [--dryrun=true|false]
- *                          [--simulate-regtest-payment=true|false]
+ *     TakeBestPricedOfferToBuyBtc  --password=api-password --port=api-port [--dryrun=true|false]
  * 
+ * If your API daemon is running on a local regtest network (with a trading peer), you can pass the + * '--simulate-regtest-payment=true' option to the program to simulate the full trade protocol. The bot will print + * your regtest trading peer's CLI commands in the console, for you to copy/paste into another terminal. + *
+ *     TakeBestPricedOfferToBuyBtc  --password=api-password --port=api-port [--simulate-regtest-payment=true|false]
+ * 
+ * + * @see bisq.bots.Config.java */ @Slf4j @Getter @@ -91,21 +96,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 +150,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(); @@ -175,7 +177,6 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot { takeCriteria.printOfferAgainstCriteria(cheapestOffer); }); - printDryRunProgress(); runCountdown(log, pollingInterval); pingDaemon(startTime); } @@ -189,16 +190,21 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot { private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { log.info("Will attempt to take offer '{}'.", offer.getId()); takeCriteria.printOfferAgainstCriteria(offer); + + // An encrypted wallet must be unlocked before calling takeoffer and gettrade(s). + // Unlock the wallet for 5 minutes. If the wallet is already unlocked, this request + // will override the timeout of the previous unlock request. + try { + unlockWallet(walletPassword, 300); + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException, pollingInterval); + } + if (isDryRun) { addToOffersTaken(offer); numOffersTaken++; - maybeShutdownAfterSuccessfulTradeCreation(); } 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); @@ -213,79 +219,13 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot { printBTCBalances("BTC Balances After Simulated Trade Completion"); } numOffersTaken++; - maybeShutdownAfterSuccessfulTradeCreation(); } 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."); + maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers); } /** @@ -298,18 +238,13 @@ 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()); var network = getNetwork(); configsByLabel.put("BTC Network:", network); + configsByLabel.put("Dry Run?", isDryRun ? "YES" : "NO"); + configsByLabel.put("Simulate Regtest Trade?", canSimulatePaymentSteps ? "YES" : "NO"); configsByLabel.put("My Payment Account:", ""); configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); @@ -339,17 +274,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 +298,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 +347,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 margin based price (%s) <= bot's target price (%s)?", + offer.getPrice() + " " + currencyCode, + targetPrice + " " + currencyCode); + 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..92f0944 --- /dev/null +++ b/java-examples/src/main/java/bisq/bots/TakeBestPricedOfferToSellXmr.java @@ -0,0 +1,368 @@ +/* + * 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; + +/** + * This bot's general use case is to buy XMR with your BTC at a low BTC price. It periodically checks the + * Sell XMR (Buy BTC) market, and takes a configured maximum number of offers to sell you XMR according to criteria you + * define in the bot's configuration file: TakeBestPricedOfferToSellXmr.properties (located in project's + * src/main/resources directory). You will need to replace the default values in the configuration file for your + * use cases. + *


+ * After the maximum number of offers have been taken (good to start with 1), the bot will shut down the API daemon, + * then itself. You have to confirm the offer maker's XMR payment(s) outside Bisq, then complete the trade(s) in + * the Bisq Desktop application. + *

+ * Here is one possible use case: + *

+ *  Take 1 offer to sell you XMR for BTC, priced no higher than 0.00% above or below current market price if:
+ *
+ *          the offer's BTC amount is between 0.50 and 1.00 BTC
+ *          the offer maker is one of two preferred trading peers
+ *          the current transaction mining fee rate is less than or equal 15 sats / byte
+ *
+ *  The bot configurations for these rules are set in TakeBestPricedOfferToSellXmr.properties as follows:
+ *
+ *          maxTakeOffers=1
+ *          maxMarketPriceMargin=0.00
+ *          minAmount=0.50
+ *          maxAmount=1.00
+ *          preferredTradingPeers=preferred-address-1.onion:9999,preferred-address-2.onion:9999
+ *          maxTxFeeRate=15
+ * 
+ * Usage + *


+ * You must encrypt your wallet password before running this bot. If it is not already encrypted, you can use the CLI: + *

+ *     $ ./bisq-cli --password=xyz --port=9998 setwalletpassword --wallet-password="be careful"
+ * 
+ * There are some {@link bisq.bots.Config program options} common to all the Java bot examples, passed on the command + * line. The only one you must provide (no default value) is your API daemon's password option: + * `--password `. The bot will prompt you for your wallet-password in the console. + *


+ * You can pass the '--dryrun=true' option to the program to see what offers your bot would take with a given + * configuration. This will help you avoid taking offers by mistake. + *

+ *     TakeBestPricedOfferToSellXmr  --password=api-password --port=api-port [--dryrun=true|false]
+ * 
+ * If your API daemon is running on a local regtest network (with a trading peer), you can pass the + * '--simulate-regtest-payment=true' option to the program to simulate the full trade protocol. The bot will print + * your regtest trading peer's CLI commands in the console, for you to copy/paste into another terminal. + *
+ *     TakeBestPricedOfferToSellXmr  --password=api-password --port=api-port [--simulate-regtest-payment=true|false]
+ * 
+ * + * @see bisq.bots.Config.java + */ +@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); + }); + + 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); + + // An encrypted wallet must be unlocked before calling takeoffer and gettrade(s). + // Unlock the wallet for 5 minutes. If the wallet is already unlocked, this request + // will override the timeout of the previous unlock request. + try { + unlockWallet(walletPassword, 300); + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException, pollingInterval); + } + + if (isDryRun) { + addToOffersTaken(offer); + numOffersTaken++; + } else { + try { + 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++; + } catch (NonFatalException nonFatalException) { + handleNonFatalException(nonFatalException, pollingInterval); + } catch (StatusRuntimeException fatalException) { + shutdownAfterTakeOfferFailure(fatalException); + } + } + maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers); + } + + /** + * 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("Dry Run?", isDryRun ? "YES" : "NO"); + configsByLabel.put("Simulate Regtest Trade?", canSimulatePaymentSteps ? "YES" : "NO"); + 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 margin based price (%s) <= bot's target price (%s)?", + offer.getPrice() + " BTC", + targetPrice + " BTC"); + 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/TakeBestPricedOfferToBuyBsq.properties b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBsq.properties index 444bc92..89e3682 100644 --- a/java-examples/src/main/resources/TakeBestPricedOfferToBuyBsq.properties +++ b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBsq.properties @@ -1,12 +1,12 @@ # Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon). -maxTakeOffers=50 +maxTakeOffers=5 # # Minimum distance from 30-day average BSQ trade price. # Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison. minMarketPriceMargin=0.00 # # Hard coded 30-day average BSQ trade price, used for development over regtest. -regtest30DayAvgBsqPrice=0.00005 +regtest30DayAvgBsqPrice=0.00004 # # Taker bot's min BTC amount to sell. The candidate SELL BTC offer's amount must be >= minAmount BTC. minAmount=0.01 @@ -19,8 +19,9 @@ maxAmount=0.90 maxTxFeeRate=25 # # Taker bot's list of preferred trading peers (their onion addresses). +# Example: preferred-address-1.onion:9999,preferred-address-2.onion:9999 # 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 +pollingInterval=60000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToBuyBtc.properties b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBtc.properties index 89783e7..50f6552 100644 --- a/java-examples/src/main/resources/TakeBestPricedOfferToBuyBtc.properties +++ b/java-examples/src/main/resources/TakeBestPricedOfferToBuyBtc.properties @@ -1,13 +1,11 @@ -# # Maximum # of offers to take during one bot session. When reached, bot will shut down API daemon then itself. -maxTakeOffers=1 +maxTakeOffers=5 # # Taker bot's payment account id. Only BUY BTC offers using the same payment method will be considered for taking. -paymentAccountId=6e58f3d9-e7a3-4799-aa38-e28e624d79a3 +paymentAccountId=9f791b7b-9b34-4931-8c93-8e7b0dc71612 # # Taker bot's min market price margin. A candidate BUY BTC offer's price margin must be >= minMarketPriceMargin. -# -minMarketPriceMargin=0 +minMarketPriceMargin=-5.00 # # Taker bot's min BTC amount to sell. The candidate BUY offer's amount must be >= minAmount BTC. minAmount=0.01 @@ -23,6 +21,7 @@ maxTxFeeRate=25 bisqTradeFeeCurrency=BSQ # # Taker bot's list of preferred trading peers (their onion addresses). +# Example: preferred-address-1.onion:9999,preferred-address-2.onion:9999 # If you do not want to constrict trading to preferred peers, comment this line out with a '#' character. preferredTradingPeers=localhost:8888, \ nysf2pknaaxfh26k42ego5mnfzpbozyi3nuoxdu745unvva4pvywffyd.onion:9999, \ @@ -35,4 +34,4 @@ preferredTradingPeers=localhost:8888, \ x6x2o3m6rxhkfuf2v6lalbharf3whwvkts5rdn3jkhgieqvnq6mvdfyd.onion:9999 # # Offer polling frequency must be >= 1s (1000ms) between each getoffers request. -pollingInterval=20000 +pollingInterval=60000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToBuyXmr.properties b/java-examples/src/main/resources/TakeBestPricedOfferToBuyXmr.properties new file mode 100644 index 0000000..98f24e7 --- /dev/null +++ b/java-examples/src/main/resources/TakeBestPricedOfferToBuyXmr.properties @@ -0,0 +1,29 @@ +# Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon). +maxTakeOffers=5 +# +# Taker bot's payment account id. Only SELL BTC offers using the same payment method will be considered for taking. +paymentAccountId=f32546cd-bb47-4bce-acc8-5227a13e2516 +# +# Taker bot's min market price margin. A candidate sell BTC offer's price margin must be >= minMarketPriceMargin. +minMarketPriceMargin=-5.00 +# +# 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=1.00 +# +# Taker bot's max acceptable transaction fee rate (sats / byte). +# Regtest fee rates are from https://price.bisq.wiz.biz/getFees +maxTxFeeRate=50 +# +# Bisq trade fee currency code (BSQ or BTC). +bisqTradeFeeCurrency=BSQ +# +# Taker bot's list of preferred trading peers (their onion addresses). +# Example: preferred-address-1.onion:9999,preferred-address-2.onion:9999 +# 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=10000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToSellBsq.properties b/java-examples/src/main/resources/TakeBestPricedOfferToSellBsq.properties index 97f13a0..3d44768 100644 --- a/java-examples/src/main/resources/TakeBestPricedOfferToSellBsq.properties +++ b/java-examples/src/main/resources/TakeBestPricedOfferToSellBsq.properties @@ -1,12 +1,12 @@ # Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon). -maxTakeOffers=50 +maxTakeOffers=5 # # Maximum distance from 30-day average BSQ trade price. # Note: all BSQ Swap offers have a fixed-price, but the bot uses a margin (%) of the 30-day price for comparison. -maxMarketPriceMargin=0.00 +maxMarketPriceMargin=-1.0 # # Hard coded 30-day average BSQ trade price, used for development over regtest. -regtest30DayAvgBsqPrice=0.00037 +regtest30DayAvgBsqPrice=0.00060 # # Taker bot's min BTC amount to buy. The candidate BUY BTC offer's amount must be >= minAmount BTC. minAmount=0.01 @@ -19,8 +19,9 @@ maxAmount=0.90 maxTxFeeRate=25 # # Taker bot's list of preferred trading peers (their onion addresses). +# Example: preferred-address-1.onion:9999,preferred-address-2.onion:9999 # 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 +pollingInterval=60000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToSellBtc.properties b/java-examples/src/main/resources/TakeBestPricedOfferToSellBtc.properties index a4f51e2..3f5099c 100644 --- a/java-examples/src/main/resources/TakeBestPricedOfferToSellBtc.properties +++ b/java-examples/src/main/resources/TakeBestPricedOfferToSellBtc.properties @@ -1,12 +1,12 @@ # # Maximum # of offers to take during one bot session. When reached, bot will shut down API daemon then itself. -maxTakeOffers=4 +maxTakeOffers=1 # # Taker bot's payment account id. Only SELL BTC offers using the same payment method will be considered for taking. -paymentAccountId=6e58f3d9-e7a3-4799-aa38-e28e624d79a3 +paymentAccountId=09dbadfd-c2ff-4bf4-b8d7-1d63e11d0238 # # Taker bot's max market price margin. A candidate SELL BTC offer's price margin must be <= maxMarketPriceMargin. -maxMarketPriceMargin=3.00 +maxMarketPriceMargin=0.00 # # Taker bot's min BTC amount to buy. The candidate SELL offer's amount must be >= minAmount BTC. minAmount=0.01 @@ -16,12 +16,13 @@ maxAmount=0.50 # # Taker bot's max acceptable transaction fee rate (sats / byte). # Regtest fee rates are from https://price.bisq.wiz.biz/getFees -maxTxFeeRate=100 +maxTxFeeRate=20 # # Bisq trade fee currency code (BSQ or BTC). bisqTradeFeeCurrency=BSQ # # Taker bot's list of preferred trading peers (their onion addresses). +# Example: preferred-address-1.onion:9999,preferred-address-2.onion:9999 # If you do not want to constrict trading to preferred peers, comment this line out with a '#' character. preferredTradingPeers=localhost:8888, \ nysf2pknaaxfh26k42ego5mnfzpbozyi3nuoxdu745unvva4pvywffyd.onion:9999, \ @@ -34,4 +35,4 @@ preferredTradingPeers=localhost:8888, \ x6x2o3m6rxhkfuf2v6lalbharf3whwvkts5rdn3jkhgieqvnq6mvdfyd.onion:9999 # # Offer polling frequency must be >= 1s (1000ms) between each getoffers request. -pollingInterval=20000 +pollingInterval=60000 diff --git a/java-examples/src/main/resources/TakeBestPricedOfferToSellXmr.properties b/java-examples/src/main/resources/TakeBestPricedOfferToSellXmr.properties new file mode 100644 index 0000000..01db613 --- /dev/null +++ b/java-examples/src/main/resources/TakeBestPricedOfferToSellXmr.properties @@ -0,0 +1,29 @@ +# Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon). +maxTakeOffers=2 +# +# Taker bot's payment account id. Only SELL BTC offers using the same payment method will be considered for taking. +paymentAccountId=fafeec6e-fb95-4ff5-a537-ea7e9d1ad683 +# +# Taker bot's max market price margin. A candidate buy BTC offer's price margin must be <= maxMarketPriceMargin. +maxMarketPriceMargin=1 +# +# 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=1.00 +# +# 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). +# Example: preferred-address-1.onion:9999,preferred-address-2.onion:9999 +# 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=60000 diff --git a/python-examples/README.md b/python-examples/README.md index da93181..907c06e 100644 --- a/python-examples/README.md +++ b/python-examples/README.md @@ -3,8 +3,37 @@ This subproject contains Python3 classes demonstrating API gRPC method calls, and some sample bots. Each class in -the [bisq.rpccalls](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples/bisq/rpccalls) package is -named for the RPC method call being demonstrated. +the [bisq.rpccalls](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples/bisq/rpccalls) package +is named for the RPC method call being demonstrated. + +The [bisq.bots](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples/bisq/rpccalls) package +contains some simple bots. Please do not run the Python bot examples on mainnet. +See [warning](#do-not-run-python-bot-examples-on-mainnet). The `run-setup.sh` script in this directory can install Python3 dependencies and example packages into a local venv. +## Risks, Warnings and Flaws + +### Never Run API Daemon and [Bisq GUI](https://bisq.network) On Same Host At Same Time + +The API daemon and the GUI share the same default wallet and connection ports. Beyond inevitable failures due to +fighting over the wallet and ports, doing so will probably corrupt your wallet. Before starting the API daemon, make +sure your GUI is shut down, and vice-versa. Please back up your mainnet wallet early and often with the GUI. + +### Go Slow (But Much Faster Than You Click Buttons In The GUI) + +[Bisq](https://bisq.network) was designed to respond to manual clicks in the user interface. It is not a +high-throughput, high-performance system supporting atomic transactions. Care must be taken to avoid problems due to +slow wallet updates on your disk, and Tor network latency. The API daemon enforces limits on request frequency via call +rate metering, but you cannot assume bots can perform tasks as rapidly as the API daemon's call rate meters allow. + +### [Do Not Run Python Bot Examples On Mainnet](#do-not-run-python-bot-examples-on-mainnet) + +The scripts in the [bisq.bots](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples/bisq/bots) +package should not be run on mainnet. They do not properly handle errors, and were written by a Python noob. + +The [Java Bot Examples](https://github.com/bisq-network/bisq-api-reference/blob/split-up-take-btc-offer-bots/java-examples/README.md) +are intended to be run on mainnet. An experienced Python developer could port these examples to Python for running on +mainnet, and offer them as a contribution to +the [Bisq API Reference](https://github.com/bisq-network/bisq-api-reference) +project. If accepted, they could be [compensated](https://bisq.wiki/Making_a_compensation_request). \ No newline at end of file