Merge pull request #12 from bisq-network/split-up-take-btc-offer-bots

Split take BTC offer bots into take BTC-FIAT and XMR-BTC offer bots
This commit is contained in:
Stan 2022-07-05 08:39:18 -03:00 committed by GitHub
commit 3170e71167
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1918 additions and 583 deletions

View File

@ -10,20 +10,23 @@ client example code, and developing new Java and Python clients and bots.
It contains four subprojects: It contains four subprojects:
1. [reference-doc-builder](https://github.com/bisq-network/bisq-api-reference/tree/main/reference-doc-builder) -- The Java 1. [reference-doc-builder](https://github.com/bisq-network/bisq-api-reference/tree/main/reference-doc-builder) -- The
application that produces the [API Reference](https://bisq-network.github.io/slate) content, from Bisq protobuf Java application that produces the [API Reference](https://bisq-network.github.io/slate) content, from Bisq protobuf
definition files. definition files.
2. [cli-examples](https://github.com/bisq-network/bisq-api-reference/tree/main/cli-examples) -- A folder of bash scripts 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. 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 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 demonstrating how to call the API from Java gRPC clients. Each class in the
the [bisq.rpccalls](https://github.com/bisq-network/bisq-api-reference/tree/main/java-examples/src/main/java/bisq/rpccalls) [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. package is named for the RPC method call being demonstrated. There are also some mainnet-ready Java API bots in the
4. [python-examples](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples) -- A Python3 project [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 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 the [bisq.rpccalls](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples/bisq/rpccalls)
is named for the RPC method call being demonstrated. There are also some simple bot examples in package is named for the RPC method call being demonstrated. There are also some simple (not-ready-for-mainnet) bot
the [bisq.bots](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples/bisq/bots) package. 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 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 navigating the RPC method links in the reference's table of contents on the left side of the page, they appear in the

View File

@ -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 Each class in
the [bisq.rpccalls](https://github.com/bisq-network/bisq-api-reference/tree/main/java-examples/src/main/java/bisq/rpccalls) 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. package is named for the RPC method call being demonstrated.
The subproject uses a Their purpose is to show how to construct a gRPC service method request with parameters, send the request, and print a
a [Gradle build file](https://github.com/bisq-network/bisq-api-reference/blob/main/java-examples/build.gradle), also response object if there is one. As a rule, the request is successful if a gRPC StatusRuntimeException is not thrown by
demonstrating how to generate the necessary protobuf classes from the Bisq .proto files. 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=<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=<trade-d>
# Close your completed trade (move it to your trade history).
$ ./bisq-cli --password=xyz --port=9998 closetrade --trade-id=<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.

View File

@ -45,6 +45,9 @@ extractdistribution
./create-runnable-jar.sh "$GRADLE_DIST_NAME" bisq.bots.TakeBestPricedOfferToBuyBtc ./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.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.TakeBestPricedOfferToBuyBsq
./create-runnable-jar.sh "$GRADLE_DIST_NAME" bisq.bots.TakeBestPricedOfferToSellBsq ./create-runnable-jar.sh "$GRADLE_DIST_NAME" bisq.bots.TakeBestPricedOfferToSellBsq

View File

@ -84,15 +84,15 @@ MAINCLASS_FILE_PATH=$(getmainclassfilepath "$FULLY_QUALIFIED_CLASSNAME")
# Extract the Main-Class from the distribution jar, to the current working directory. # 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" jar xfv "lib/$GRADLE_DIST_NAME.jar" "$MAINCLASS_FILE_PATH" "$SIMPLE_CLASSNAME.properties"
echo "Extracted one class:" echo "Extracted $SIMPLE_CLASSNAME.class:"
ls -l bisq/bots/pazza ls -l "bisq/bots/$SIMPLE_CLASSNAME.class"
mv "$SIMPLE_CLASSNAME.properties" "$JAR_BASENAME.conf" mv "$SIMPLE_CLASSNAME.properties" "$JAR_BASENAME.conf"
echo "Extracted one properties file and renamed it $JAR_BASENAME.conf" echo "Extracted $SIMPLE_CLASSNAME.properties and renamed it $JAR_BASENAME.conf"
ls -l *.conf ls -l "$JAR_BASENAME.conf"
# Now it can be added to the empty jar with the correct path. # Now it can be added to the empty jar with the correct path.
jar uf "$JAR_BASENAME.jar" "$MAINCLASS_FILE_PATH" jar uf "$JAR_BASENAME.jar" "$MAINCLASS_FILE_PATH"
# Remove bisq (bisq/bots/junk). # Remove workarea.
rm -rf bisq rm -rf bisq
echo "Runnable $JAR_BASENAME.jar is ready to use." echo "Runnable $JAR_BASENAME.jar is ready to use."

View File

@ -20,6 +20,7 @@ import bisq.bots.table.builder.TableBuilder;
import bisq.proto.grpc.*; import bisq.proto.grpc.*;
import bisq.proto.grpc.GetTradesRequest.Category; import bisq.proto.grpc.GetTradesRequest.Category;
import io.grpc.StatusRuntimeException; import io.grpc.StatusRuntimeException;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger; import org.slf4j.Logger;
import protobuf.PaymentAccount; 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.BSQ_BALANCE_TBL;
import static bisq.bots.table.builder.TableType.BTC_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.GetOfferCategoryReply.OfferCategory.BSQ_SWAP;
import static bisq.proto.grpc.GetTradesRequest.Category.CLOSED;
import static io.grpc.Status.*; import static io.grpc.Status.*;
import static java.lang.String.format; import static java.lang.String.format;
import static java.lang.System.exit; import static java.lang.System.exit;
@ -61,6 +63,7 @@ public abstract class AbstractBot {
protected final String walletPassword; protected final String walletPassword;
protected final String conf; protected final String conf;
protected final GrpcStubs grpcStubs; protected final GrpcStubs grpcStubs;
@Getter
protected final boolean isDryRun; protected final boolean isDryRun;
// This is an experimental option for simulating and automating protocol payment steps during bot development. // 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. // Be extremely careful in its use; You do not want to "simulate" payments when API daemon is connected to mainnet.
@ -71,8 +74,8 @@ public abstract class AbstractBot {
protected final List<String> preferredTradingPeers = new ArrayList<>(); protected final List<String> preferredTradingPeers = new ArrayList<>();
// Used during dry runs to track offers that would be taken. // 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. // This list should stay empty when dryRun = false.
protected final List<OfferInfo> offersTaken = new ArrayList<>(); protected final List<OfferInfo> offersTakenDuringDryRun = new ArrayList<>();
protected final boolean canUseBash = getBashPath().isPresent(); protected final boolean canUseBash = getBashPath().isPresent();
protected boolean isShutdown = false; protected boolean isShutdown = false;
@ -82,9 +85,20 @@ public abstract class AbstractBot {
protected final Supplier<Long> minimumTxFeeRate = () -> txFeeRates.get().getMinFeeServiceRate(); protected final Supplier<Long> minimumTxFeeRate = () -> txFeeRates.get().getMinFeeServiceRate();
protected final Supplier<Long> mostRecentTxFeeRate = () -> txFeeRates.get().getFeeServiceRate(); protected final Supplier<Long> mostRecentTxFeeRate = () -> txFeeRates.get().getFeeServiceRate();
// Constructor /**
* Constructor that optionally prompts user to enter wallet password in the console.
* <p>
* The wallet password prompt will be skipped if the given program args array already contains the
* '--wallet-password' option. This situation can occur when a bot calls another bot, and passes its
* wallet password option with validated value to this constructor.
* <p>
*
* @param args program arguments
*/
public AbstractBot(String[] args) { 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()); Config bisqClientOpts = new Config(this.args, defaultPropertiesFilename.get());
this.walletPassword = bisqClientOpts.getWalletPassword(); this.walletPassword = bisqClientOpts.getWalletPassword();
this.conf = bisqClientOpts.getConf(); this.conf = bisqClientOpts.getConf();
@ -113,7 +127,11 @@ public abstract class AbstractBot {
var reply = grpcStubs.versionService.getVersion(request); var reply = grpcStubs.versionService.getVersion(request);
log.info("API daemon {} is available.", reply.getVersion()); log.info("API daemon {} is available.", reply.getVersion());
} catch (StatusRuntimeException grpcException) { } 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); exit(1);
} }
} }
@ -192,10 +210,10 @@ public abstract class AbstractBot {
} }
/** /**
* Return true if bot has taken the offer during this session -- for dry runs only. * Return true if bot is in dryrun mode, and has taken the offer during this session.
*/ */
protected final Predicate<OfferInfo> isAlreadyTaken = (offer) -> protected final Predicate<OfferInfo> isAlreadyTaken = (offer) ->
offersTaken.stream().anyMatch(o -> o.getId().equals(offer.getId())); this.isDryRun() && offersTakenDuringDryRun.stream().anyMatch(o -> o.getId().equals(offer.getId()));
/** /**
* Print a table of BSQ balance information. * 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 * 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. * 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.) * XMR payment account, else throws an IllegalStateException. (The bot does not yet support BSQ Swaps.)
*/ */
protected void validatePaymentAccount(PaymentAccount paymentAccount) { 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()) if (!paymentAccount.hasSelectedTradeCurrency())
throw new IllegalStateException( 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.getId(),
paymentAccount.getAccountName())); paymentAccount.getAccountName()));
var selectedCurrencyCode = paymentAccount.getSelectedTradeCurrency().getCode(); 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. // 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)) if (isBlockChainsPaymentMethod && !isXmr.test(selectedCurrencyCode))
throw new IllegalStateException( throw new IllegalStateException(
format("This bot only supports fiat and monero (XMR) trading, not the %s altcoin.", format("This bot only supports fiat and monero (XMR) trading, not the %s altcoin.",
selectedCurrencyCode)); selectedCurrencyCode));
if (isBsq.test(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); 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. * Print list of today's trade summaries to stdout.
* *
@ -536,7 +613,12 @@ public abstract class AbstractBot {
var trades = getTrades(category).stream() var trades = getTrades(category).stream()
.filter(t -> t.getDate() >= midnightToday) .filter(t -> t.getDate() >= midnightToday)
.collect(Collectors.toList()); .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. * Print information about offers taken during bot simulation.
*/ */
protected void printDryRunProgress() { protected void printDryRunProgress() {
if (isDryRun && offersTaken.size() > 0) { if (isDryRun && !offersTakenDuringDryRun.isEmpty()) {
log.info("You have \"taken\" {} offer(s) during dry run:", offersTaken.size()); printOffersSummary(offersTakenDuringDryRun);
printOffersSummary(offersTaken);
} }
} }
@ -642,9 +723,7 @@ public abstract class AbstractBot {
* Add offer to list of taken offers -- for dry runs only. * Add offer to list of taken offers -- for dry runs only.
*/ */
protected void addToOffersTaken(OfferInfo offer) { protected void addToOffersTaken(OfferInfo offer) {
offersTaken.add(offer); offersTakenDuringDryRun.add(offer);
printOfferSummary(offer);
log.info("Did not actually take that offer during this simulation.");
} }
/** /**
@ -666,6 +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). * 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); exit(1);
} }
/**
* Print the day's completed trades since midnight today, and number of offers taken during the bot session, then,
* <p>
* If numOffersTaken >= maxTakeOffers, shut down the bot.
* <p>
* 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,
* <p>
* If numOffersTaken >= maxTakeOffers, and trade completion is being simulated on regtest, shut down the bot.
* <p>
* If numOffersTaken >= maxTakeOffers, and mainnet trade completion must be delegated to the UI, shut down the
* API daemon and the bot.
* <p>
* If numOffersTaken < maxTakeOffers, just log the number of offers taken so far during the bot run.
* (Don't shut down anything.)
*
* @param numOffersTaken the number of offers taken during bot run
* @param maxTakeOffers the max number of offers that can be taken during bot run
*/
protected void maybeShutdownAfterSuccessfulTradeCreation(int numOffersTaken, int maxTakeOffers) {
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. * Returns Properties object for this bot.
* *
@ -705,7 +888,11 @@ public abstract class AbstractBot {
try { try {
var defaultFilename = defaultPropertiesFilename.get(); var defaultFilename = defaultPropertiesFilename.get();
properties.load(this.getClass().getClassLoader().getResourceAsStream(defaultFilename)); 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) { } catch (Exception ex) {
throw new IllegalStateException(ex); throw new IllegalStateException(ex);
} }
@ -726,7 +913,11 @@ public abstract class AbstractBot {
Properties properties = new java.util.Properties(); Properties properties = new java.util.Properties();
try { try {
properties.load(is); 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; return properties;
} catch (FileNotFoundException ignored) { } catch (FileNotFoundException ignored) {
// Cannot happen here. Ignore FileNotFoundException because confFile.exists() == true. // Cannot happen here. Ignore FileNotFoundException because confFile.exists() == true.

View File

@ -42,6 +42,7 @@ import static java.lang.String.format;
import static java.lang.System.*; import static java.lang.System.*;
import static java.math.BigDecimal.ZERO; import static java.math.BigDecimal.ZERO;
import static java.math.RoundingMode.HALF_UP; 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. * 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() (offer, targetPrice) -> offer.getUseMarketBasedPrice()
&& new BigDecimal(offer.getPrice()).compareTo(targetPrice) >= 0; && new BigDecimal(offer.getPrice()).compareTo(targetPrice) >= 0;
/**
* Return true if the margin price based offer's market price margin (%) >= minxMarketPriceMargin (%).
*/
public static final BiPredicate<OfferInfo, BigDecimal> isMarginGEMinMarketPriceMargin =
(offer, minMarketPriceMargin) -> offer.getUseMarketBasedPrice()
&& offer.getMarketPriceMarginPct() >= minMarketPriceMargin.doubleValue();
/** /**
* Return true if the margin price based offer's market price margin (%) <= maxMarketPriceMargin (%). * 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 distanceFromMarketPrice.compareTo(minMarketPriceMargin) >= 0;
} }
/**
* Return String "above" if minMarketPriceMargin (%) >= 0.00, else "below".
*/
public static final Function<BigDecimal, String> aboveOrBelowMinMarketPriceMargin = (minMarketPriceMargin) ->
minMarketPriceMargin.compareTo(ZERO) >= 0 ? "above" : "below";
/** /**
* Return String "below" if maxMarketPriceMargin (%) <= 0.00, else "above". * Return String "below" if maxMarketPriceMargin (%) <= 0.00, else "above".
*/ */
public static final Function<BigDecimal, String> aboveOrBelowMarketPrice = (maxMarketPriceMargin) -> public static final Function<BigDecimal, String> aboveOrBelowMaxMarketPriceMargin = (maxMarketPriceMargin) ->
maxMarketPriceMargin.compareTo(ZERO) <= 0 ? "below" : "above"; maxMarketPriceMargin.compareTo(ZERO) <= 0 ? "below" : "above";
/** /**
@ -269,6 +283,12 @@ public class BotUtils {
return appendWalletPasswordOpt(args, unvalidatedWalletPassword); return appendWalletPasswordOpt(args, unvalidatedWalletPassword);
}; };
/**
* Return true if the '--wallet-password' option label if found in the given program args array.
*/
public static final Predicate<String[]> hasWalletPasswordOpt = (args) ->
Arrays.stream(args).anyMatch(a -> a.contains("--wallet-password"));
/** /**
* Return a wallet password read from stdin. If read from a command terminal, input will not be echoed. * 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. * If run in a virtual terminal (IDE console), the input will be echoed.
@ -388,6 +408,7 @@ public class BotUtils {
* @param offer printed offer * @param offer printed offer
*/ */
public static void printOfferSummary(OfferInfo offer) { public static void printOfferSummary(OfferInfo offer) {
requireNonNull(offer, "OfferInfo offer param cannot be null.");
new TableBuilder(OFFER_TBL, offer).build().print(out); new TableBuilder(OFFER_TBL, offer).build().print(out);
} }
@ -397,7 +418,12 @@ public class BotUtils {
* @param offers printed offer list * @param offers printed offer list
*/ */
public static void printOffersSummary(List<OfferInfo> offers) { public static void printOffersSummary(List<OfferInfo> offers) {
new TableBuilder(OFFER_TBL, offers).build().print(out); requireNonNull(offers, "List<OfferInfo> 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 * @param trade printed trade
*/ */
public static void printTradeSummary(TradeInfo trade) { public static void printTradeSummary(TradeInfo trade) {
requireNonNull(trade, "TradeInfo trade param cannot be null.");
new TableBuilder(TRADE_DETAIL_TBL, trade).build().print(out); new TableBuilder(TRADE_DETAIL_TBL, trade).build().print(out);
} }
@ -416,10 +443,15 @@ public class BotUtils {
* @param trades list of trades * @param trades list of trades
*/ */
public static void printTradesSummary(GetTradesRequest.Category category, List<TradeInfo> trades) { public static void printTradesSummary(GetTradesRequest.Category category, List<TradeInfo> trades) {
switch (category) { requireNonNull(trades, "List<TradeInfo> trades param cannot be null.");
case CLOSED -> new TableBuilder(CLOSED_TRADES_TBL, trades).build().print(out); if (trades.isEmpty()) {
case FAILED -> new TableBuilder(FAILED_TRADES_TBL, trades).build().print(out); log.info("No trades to print.");
default -> new TableBuilder(OPEN_TRADES_TBL, trades).build().print(out); } 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 * @param paymentAccount the printed PaymentAccount
*/ */
public static void printPaymentAccountSummary(PaymentAccount paymentAccount) { public static void printPaymentAccountSummary(PaymentAccount paymentAccount) {
requireNonNull(paymentAccount, "PaymentAccount paymentAccount param cannot be null.");
new TableBuilder(PAYMENT_ACCOUNT_TBL, paymentAccount).build().print(out); new TableBuilder(PAYMENT_ACCOUNT_TBL, paymentAccount).build().print(out);
} }
@ -519,6 +552,44 @@ public class BotUtils {
log.warn(BANNER); 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. * Run a bash script to count down the given number of seconds, printing each character of output from stdout.
* <p> * <p>

View File

@ -17,6 +17,7 @@
package bisq.bots; package bisq.bots;
import joptsimple.BuiltinHelpFormatter;
import joptsimple.OptionParser; import joptsimple.OptionParser;
import lombok.Getter; import lombok.Getter;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -63,12 +64,12 @@ public class Config {
.withRequiredArg(); .withRequiredArg();
var confOpt = parser.accepts("conf", "Bot configuration file (required)") var confOpt = parser.accepts("conf", "Bot configuration file (required)")
.withRequiredArg(); .withRequiredArg();
var dryRunOpt = parser.accepts("dryrun", "Pretend to take an offer (default=false)") var dryRunOpt = parser.accepts("dryrun", "Pretend to take an offer")
.withRequiredArg() .withRequiredArg()
.ofType(boolean.class) .ofType(boolean.class)
.defaultsTo(FALSE); .defaultsTo(FALSE);
var simulateRegtestPaymentStepsOpt = var simulateRegtestPaymentStepsOpt =
parser.accepts("simulate-regtest-payment", "Simulate regtest payment steps (default=false)") parser.accepts("simulate-regtest-payment", "Simulate regtest payment steps")
.withOptionalArg() .withOptionalArg()
.ofType(boolean.class) .ofType(boolean.class)
.defaultsTo(FALSE); .defaultsTo(FALSE);
@ -126,10 +127,23 @@ public class Config {
stream.println(); stream.println();
stream.println("Usage: ScriptName [options]"); stream.println("Usage: ScriptName [options]");
stream.println(); stream.println();
parser.formatHelpWith(new HelpFormatter(90, 2));
parser.printHelpOn(stream); parser.printHelpOn(stream);
stream.println(); stream.println();
} catch (IOException ex) { } catch (IOException ex) {
ex.printStackTrace(stream); 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);
}
}
} }

View File

@ -63,9 +63,9 @@ final class GrpcStubs {
public void close() { public void close() {
try { try {
if (!channel.isShutdown()) { if (!channel.isShutdown()) {
log.info("Shutting down bot's grpc channel."); log.debug("Shutting down bot's grpc channel.");
channel.shutdown().awaitTermination(1, SECONDS); channel.shutdown().awaitTermination(1, SECONDS);
log.info("Bot channel shutdown complete."); log.debug("Bot channel shutdown complete.");
} }
} catch (InterruptedException ex) { } catch (InterruptedException ex) {
throw new IllegalStateException(ex); throw new IllegalStateException(ex);

View File

@ -24,7 +24,6 @@ import protobuf.PaymentAccount;
import java.util.Properties; import java.util.Properties;
import static bisq.bots.BotUtils.*; import static bisq.bots.BotUtils.*;
import static bisq.proto.grpc.GetTradesRequest.Category.CLOSED;
import static io.grpc.Status.Code.PERMISSION_DENIED; import static io.grpc.Status.Code.PERMISSION_DENIED;
/** /**
@ -106,18 +105,13 @@ public class RegtestTradePaymentSimulator extends AbstractBot {
closeTrade(tradeId); closeTrade(tradeId);
log.info("You closed the trade here in the bot (mandatory, to move trades to history list)."); log.info("You closed the trade here in the bot (mandatory, to move trades to history list).");
log.warn("##############################################################################"); String cliCommandDescription = "Trading peer inspects and closes trade in the CLI (mandatory, to move trades to history list):";
log.warn("Bob 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()
String copyPasteCliCommands = "./bisq-cli --password=xyz --port=9999 closetrade --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"; + "\n" + "./bisq-cli --password=xyz --port=9999 gettrades --category=closed";
log.warn(copyPasteCliCommands); printCliCommands(log, cliCommandDescription, copyPasteCliCommands);
log.warn("##############################################################################");
sleep(pollingInterval); log.debug("Closing {}'s gRPC channel.", this.getClass().getSimpleName());
log.info("Trade is completed. Here are today's completed trades:");
printTradesSummaryForToday(CLOSED);
log.info("Closing {}'s gRPC channel.", this.getClass().getSimpleName());
super.grpcStubs.close(); super.grpcStubs.close();
} }

View File

@ -25,48 +25,89 @@ import protobuf.PaymentAccount;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.*; import java.util.*;
import java.util.function.BiPredicate; import java.util.function.BiPredicate;
import java.util.function.Predicate;
import static bisq.bots.BotUtils.*; import static bisq.bots.BotUtils.*;
import static bisq.proto.grpc.GetTradesRequest.Category.CLOSED;
import static java.lang.String.format; import static java.lang.String.format;
import static java.lang.System.exit;
import static java.math.RoundingMode.HALF_UP; import static java.math.RoundingMode.HALF_UP;
import static protobuf.OfferDirection.SELL; 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: <b>TakeBestPricedOfferToBuyBsq.properties</b> (located in project's
* src/main/resources directory). You will need to replace the default values in the configuration file for your
* use cases.
* <p><br/>
* 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.
* <p> * <p>
* I'm taking liberties with the classname by not naming it TakeBestPricedOfferToSellBtcForBsq. * Here is one possible use case:
* <pre>
* 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
* </pre>
* <b>Usage</b>
* <p><br/>
* You must encrypt your wallet password before running this bot. If it is not already encrypted, you can use the CLI:
* <pre>
* $ ./bisq-cli --password=xyz --port=9998 setwalletpassword --wallet-password="be careful"
* </pre>
* 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 <String>`. The bot will prompt you for your wallet-password in the console.
* <p><br/>
* You can pass the '--dryrun=true' option to the program to see what offers your bot <i>would take</i> with a given
* configuration. This will help you avoid taking offers by mistake.
* <pre>
* TakeBestPricedOfferToBuyBsq --password=api-password --port=api-port [--dryrun=true|false]
* </pre>
* <p>
* The '--simulate-regtest-payment=true' option is ignored by this bot. Taking a swap triggers execution of the swap.
*
* @see <a href="https://github.com/bisq-network/bisq-api-reference/blob/make-proto-downloader-runnable-from-any-dir/java-examples/src/main/java/bisq/bots/Config.java">bisq.bots.Config.java</a>
*/ */
@Slf4j @Slf4j
@Getter @Getter
public class TakeBestPricedOfferToBuyBsq extends AbstractBot { 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. // Config file: resources/TakeBestPricedOfferToBuyBsq.properties.
private final Properties configFile; private final Properties configFile;
// Taker bot's default BSQ Swap payment account. // Taker bot's default BSQ Swap payment account.
private final PaymentAccount paymentAccount; 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 (%). // 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. // 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; private final BigDecimal minMarketPriceMargin;
// Hard coded 30-day average BSQ trade price, used for development over regtest (ignored when running on mainnet). // Hard coded 30-day average BSQ trade price, used for development over regtest (ignored when running on mainnet).
private final BigDecimal regtest30DayAvgBsqPrice; 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; 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; private final BigDecimal maxAmount;
// Taker bot's max acceptable transaction fee rate. // Taker bot's max acceptable transaction fee rate.
private final long maxTxFeeRate; 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; private final int maxTakeOffers;
// Offer polling frequency must be > 1000 ms between each getoffers request. // Offer polling frequency must be > 1000 ms between each getoffers request.
private final long pollingInterval; 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; private int numOffersTaken = 0;
public TakeBestPricedOfferToBuyBsq(String[] args) { 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. pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available.
this.configFile = loadConfigFile(); this.configFile = loadConfigFile();
this.paymentAccount = getBsqSwapPaymentAccount(); this.paymentAccount = getBsqSwapPaymentAccount();
this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode();
this.minMarketPriceMargin = new BigDecimal(configFile.getProperty("minMarketPriceMargin")) this.minMarketPriceMargin = new BigDecimal(configFile.getProperty("minMarketPriceMargin"))
.setScale(2, HALF_UP); .setScale(2, HALF_UP);
this.regtest30DayAvgBsqPrice = new BigDecimal(configFile.getProperty("regtest30DayAvgBsqPrice")) this.regtest30DayAvgBsqPrice = new BigDecimal(configFile.getProperty("regtest30DayAvgBsqPrice"))
@ -104,8 +144,9 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot {
continue; continue;
} }
// Get all available and takeable offers, sorted by price descending. // Get all available sell BTC for BSQ offers, sorted by price descending.
var offers = getOffers(SELL.name(), currencyCode).stream() // The list contains only fixed-priced offers.
var offers = getOffers(SELL.name(), CURRENCY_CODE).stream()
.filter(o -> !isAlreadyTaken.test(o)) .filter(o -> !isAlreadyTaken.test(o))
.toList(); .toList();
@ -130,7 +171,6 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot {
takeCriteria.printOfferAgainstCriteria(highestPricedOffer); takeCriteria.printOfferAgainstCriteria(highestPricedOffer);
}); });
printDryRunProgress();
runCountdown(log, pollingInterval); runCountdown(log, pollingInterval);
pingDaemon(startTime); pingDaemon(startTime);
} }
@ -139,17 +179,21 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot {
private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) {
log.info("Will attempt to take offer '{}'.", offer.getId()); log.info("Will attempt to take offer '{}'.", offer.getId());
takeCriteria.printOfferAgainstCriteria(offer); 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) { if (isDryRun) {
addToOffersTaken(offer); addToOffersTaken(offer);
numOffersTaken++; numOffersTaken++;
maybeShutdownAfterSuccessfulSwap();
} else { } 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 { try {
unlockWallet(walletPassword, 600);
printBTCBalances("BTC Balances Before Swap Execution"); printBTCBalances("BTC Balances Before Swap Execution");
printBSQBalances("BSQ 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"); printBSQBalances("BSQ Balances After Swap Execution");
numOffersTaken++; numOffersTaken++;
maybeShutdownAfterSuccessfulSwap();
} catch (NonFatalException nonFatalException) { } catch (NonFatalException nonFatalException) {
handleNonFatalException(nonFatalException); handleNonFatalException(nonFatalException, pollingInterval);
} catch (StatusRuntimeException fatalException) { } catch (StatusRuntimeException fatalException) {
handleFatalException(fatalException); handleFatalBsqSwapException(fatalException);
} }
} }
maybeShutdownAfterSuccessfulSwap(numOffersTaken, maxTakeOffers);
} }
private void printBotConfiguration() { private void printBotConfiguration() {
@ -174,12 +218,13 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot {
configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion()); configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion());
var network = getNetwork(); var network = getNetwork();
configsByLabel.put("BTC Network:", network); configsByLabel.put("BTC Network:", network);
configsByLabel.put("Dry Run?", isDryRun ? "YES" : "NO");
var isMainnet = network.equalsIgnoreCase("mainnet"); var isMainnet = network.equalsIgnoreCase("mainnet");
var mainnet30DayAvgBsqPrice = isMainnet ? get30DayAvgBsqPriceInBtc() : null; var mainnet30DayAvgBsqPrice = isMainnet ? get30DayAvgBsqPriceInBtc() : null;
configsByLabel.put("My Payment Account:", ""); configsByLabel.put("My Payment Account:", "");
configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); configsByLabel.put("\tPayment Account Id:", paymentAccount.getId());
configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName());
configsByLabel.put("\tCurrency Code:", currencyCode); configsByLabel.put("\tCurrency Code:", CURRENCY_CODE);
configsByLabel.put("Trading Rules:", ""); configsByLabel.put("Trading Rules:", "");
configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers); configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers);
configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte"); 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.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 * 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. * fixed-priced offer if the price is >= {@link #minMarketPriceMargin} (%) of the current market price.
*/ */
protected final BiPredicate<OfferInfo, BigDecimal> isFixedPriceGEMaxMarketPriceMargin = protected final BiPredicate<OfferInfo, BigDecimal> isFixedPriceGEMaxMarketPriceMargin =
(offer, currentMarketPrice) -> BotUtils.isFixedPriceGEMinMarketPriceMargin( (offer, currentMarketPrice) -> isFixedPriceGEMinMarketPriceMargin(
offer, offer,
currentMarketPrice, currentMarketPrice,
this.getMinMarketPriceMargin()); this.getMinMarketPriceMargin());
/**
* Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries).
* TODO API's takeoffer needs to support taking offer's minAmount.
*/
protected final Predicate<OfferInfo> isWithinBTCAmountBounds = (offer) ->
BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
public static void main(String[] args) { public static void main(String[] args) {
TakeBestPricedOfferToBuyBsq bot = new TakeBestPricedOfferToBuyBsq(args); TakeBestPricedOfferToBuyBsq bot = new TakeBestPricedOfferToBuyBsq(args);
bot.run(); bot.run();
@ -297,28 +288,28 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot {
.filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
.filter(isMakerPreferredTradingPeer) .filter(isMakerPreferredTradingPeer)
.filter(o -> isFixedPriceGEMaxMarketPriceMargin.test(o, avgBsqPrice)) .filter(o -> isFixedPriceGEMaxMarketPriceMargin.test(o, avgBsqPrice))
.filter(isWithinBTCAmountBounds) .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
.findFirst(); .findFirst();
else else
return offers.stream() return offers.stream()
.filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
.filter(o -> isFixedPriceGEMaxMarketPriceMargin.test(o, avgBsqPrice)) .filter(o -> isFixedPriceGEMaxMarketPriceMargin.test(o, avgBsqPrice))
.filter(isWithinBTCAmountBounds) .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
.findFirst(); .findFirst();
} }
void printCriteriaSummary() { void printCriteriaSummary() {
if (isZero.test(minMarketPriceMargin)) { 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.", + " the 30-day average BSQ trade price of {} BTC.",
MARKET_DESCRIPTION, MARKET_DESCRIPTION,
avgBsqPrice); avgBsqPrice);
} else { } 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.", + " {}% {} the 30-day average BSQ trade price of {} BTC.",
MARKET_DESCRIPTION, MARKET_DESCRIPTION,
minMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below". minMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below".
aboveOrBelowMarketPrice.apply(minMarketPriceMargin), aboveOrBelowMinMarketPriceMargin.apply(minMarketPriceMargin),
avgBsqPrice); avgBsqPrice);
} }
} }
@ -343,13 +334,13 @@ public class TakeBestPricedOfferToBuyBsq extends AbstractBot {
iHavePreferredTradingPeers.get() iHavePreferredTradingPeers.get()
? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO" ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO"
: "N/A"); : "N/A");
var fixedPriceLabel = format("Is offer fixed-price (%s) >= bot's minimum price (%s)?", var fixedPriceLabel = format("Is offer fixed-price (%s) >= bot's minimum price of (%s)?",
offer.getPrice() + " " + currencyCode, offer.getPrice() + " BTC",
targetPrice + " " + currencyCode); targetPrice + " BTC");
filterResultsByLabel.put(fixedPriceLabel, isFixedPriceGEMaxMarketPriceMargin.test(offer, avgBsqPrice)); filterResultsByLabel.put(fixedPriceLabel, isFixedPriceGEMaxMarketPriceMargin.test(offer, avgBsqPrice));
var btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount); var btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount);
filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?", 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()); var title = format("Fixed price BSQ swap offer %s filter results:", offer.getId());
log.info(toTable.apply(title, filterResultsByLabel)); log.info(toTable.apply(title, filterResultsByLabel));

View File

@ -25,58 +25,64 @@ import protobuf.PaymentAccount;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.*; import java.util.*;
import java.util.function.BiPredicate; import java.util.function.BiPredicate;
import java.util.function.Predicate;
import java.util.function.Supplier;
import static bisq.bots.BotUtils.*; import static bisq.bots.BotUtils.*;
import static java.lang.String.format; import static java.lang.String.format;
import static java.lang.System.exit;
import static java.math.RoundingMode.HALF_UP; import static java.math.RoundingMode.HALF_UP;
import static protobuf.OfferDirection.BUY; 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 * This bot's general use case is to sell your BTC for fiat at a high BTC price. It periodically checks the
* (up to a maximum of configured {@link #maxTakeOffers}), then shuts down both the API daemon and itself (the bot), * Buy BTC market, and takes a configured maximum number of offers to buy BTC from you according to criteria you
* to allow the user to start the desktop UI application and complete the trades. * define in the bot's configuration file: <b>TakeBestPricedOfferToBuyBtc.properties</b> (located in project's
* src/main/resources directory). You will need to replace the default values in the configuration file for your
* use cases.
* <p><br/>
* 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 <a href="https://bisq.network">Bisq Desktop</a> application.
* <p> * <p>
* The benefit this bot provides is freeing up the user time spent watching the offer book in the UI, waiting for the * Here is one possible use case:
* right offer to take. This bot increases the chance of beating the other nodes at taking the offer.
* <p>
* 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.
* <p>
* 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.
* <p>
* One possible use case for this bot is sell BTC for GBP:
* <pre> * <pre>
* Take a "Faster Payment (Santander)" offer to buy BTC with GBP at or above current market price if: * Take 3 "Faster Payment" offers to buy BTC with GBP, priced no lower than 2.00% above the current market
* the offer maker is a preferred trading peer, * price if:
* 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. * 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
* </pre> * </pre>
* <p> * <b>Usage</b>
* Another possible use case for this bot is to buy BTC with XMR. (We might say "sell XMR for BTC", but we need to * <p><br/>
* remember that all Bisq offers are for buying or selling BTC.) * You must encrypt your wallet password before running this bot. If it is not already encrypted, you can use the CLI:
* <pre> * <pre>
* Take an offer to buy BTC with XMR at or above current market price if: * $ ./bisq-cli --password=xyz --port=9998 setwalletpassword --wallet-password="be careful"
* the offer maker is a preferred trading peer,
* and the offer's BTC amount is between 0.50 and 1.00 BTC,
* and the current transaction mining fee rate is below 15 sats / byte.
* </pre> * </pre>
* <p> * 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 <String>`. The bot will prompt you for your wallet-password in the console.
* <p><br/>
* You can pass the '--dryrun=true' option to the program to see what offers your bot <i>would take</i> with a given
* configuration. This will help you avoid taking offers by mistake.
* <pre> * <pre>
* Usage: TakeBestPricedOfferToBuyBtc --password=api-password --port=api-port \ * TakeBestPricedOfferToBuyBtc --password=api-password --port=api-port [--dryrun=true|false]
* [--conf=take-best-priced-offer-to-buy-btc.conf] \
* [--dryrun=true|false]
* [--simulate-regtest-payment=true|false]
* </pre> * </pre>
* 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.
* <pre>
* TakeBestPricedOfferToBuyBtc --password=api-password --port=api-port [--simulate-regtest-payment=true|false]
* </pre>
*
* @see <a href="https://github.com/bisq-network/bisq-api-reference/blob/make-proto-downloader-runnable-from-any-dir/java-examples/src/main/java/bisq/bots/Config.java">bisq.bots.Config.java</a>
*/ */
@Slf4j @Slf4j
@Getter @Getter
@ -90,21 +96,21 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
private final String currencyCode; private final String currencyCode;
// Taker bot's min market price margin. A takeable offer's price margin (%) must be >= minMarketPriceMargin (%). // Taker bot's min market price margin. A takeable offer's price margin (%) must be >= minMarketPriceMargin (%).
private final BigDecimal 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; 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; private final BigDecimal maxAmount;
// Taker bot's max acceptable transaction fee rate. // Taker bot's max acceptable transaction fee rate.
private final long maxTxFeeRate; private final long maxTxFeeRate;
// Taker bot's trading fee currency code (BSQ or BTC). // Taker bot's trading fee currency code (BSQ or BTC).
private final String bisqTradeFeeCurrency; 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; private final int maxTakeOffers;
// Offer polling frequency must be > 1000 ms between each getoffers request. // Offer polling frequency must be > 1000 ms between each getoffers request.
private final long pollingInterval; 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; private int numOffersTaken = 0;
public TakeBestPricedOfferToBuyBtc(String[] args) { public TakeBestPricedOfferToBuyBtc(String[] args) {
@ -144,12 +150,9 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
continue; continue;
} }
// Taker bot's getOffers(direction) request param. For fiat offers, is BUY (BTC), for XMR offers, is SELL (BTC). // Get all available and takeable buy BTC for fiat offers, sorted by price descending.
String offerDirection = isXmr.test(currencyCode) ? SELL.name() : BUY.name();
// Get all available and takeable offers, sorted by price ascending.
// The list contains both fixed-price and market price margin based offers. // 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)) .filter(o -> !isAlreadyTaken.test(o))
.toList(); .toList();
@ -174,7 +177,6 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
takeCriteria.printOfferAgainstCriteria(highestPricedOffer); takeCriteria.printOfferAgainstCriteria(highestPricedOffer);
}); });
printDryRunProgress();
runCountdown(log, pollingInterval); runCountdown(log, pollingInterval);
pingDaemon(startTime); pingDaemon(startTime);
} }
@ -188,16 +190,21 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) {
log.info("Will attempt to take offer '{}'.", offer.getId()); log.info("Will attempt to take offer '{}'.", offer.getId());
takeCriteria.printOfferAgainstCriteria(offer); 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) { if (isDryRun) {
addToOffersTaken(offer); addToOffersTaken(offer);
numOffersTaken++; numOffersTaken++;
maybeShutdownAfterSuccessfulTradeCreation();
} else { } 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 { try {
unlockWallet(walletPassword, 600);
printBTCBalances("BTC Balances Before Take Offer Attempt"); printBTCBalances("BTC Balances Before Take Offer Attempt");
// Blocks until new trade is prepared, or times out. // Blocks until new trade is prepared, or times out.
takeV1ProtocolOffer(offer, paymentAccount, bisqTradeFeeCurrency, pollingInterval); takeV1ProtocolOffer(offer, paymentAccount, bisqTradeFeeCurrency, pollingInterval);
@ -212,79 +219,13 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
printBTCBalances("BTC Balances After Simulated Trade Completion"); printBTCBalances("BTC Balances After Simulated Trade Completion");
} }
numOffersTaken++; numOffersTaken++;
maybeShutdownAfterSuccessfulTradeCreation();
} catch (NonFatalException nonFatalException) { } catch (NonFatalException nonFatalException) {
handleNonFatalException(nonFatalException); handleNonFatalException(nonFatalException, pollingInterval);
} catch (StatusRuntimeException fatalException) { } catch (StatusRuntimeException fatalException) {
handleFatalException(fatalException); shutdownAfterTakeOfferFailure(fatalException);
} }
} }
} maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers);
/**
* 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.");
} }
/** /**
@ -297,18 +238,13 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
currentMarketPrice, currentMarketPrice,
this.getMinMarketPriceMargin()); this.getMinMarketPriceMargin());
/**
* Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries).
* TODO API's takeoffer needs to support taking offer's minAmount.
*/
protected final Predicate<OfferInfo> isWithinBTCAmountBounds = (offer) ->
BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
private void printBotConfiguration() { private void printBotConfiguration() {
var configsByLabel = new LinkedHashMap<String, Object>(); var configsByLabel = new LinkedHashMap<String, Object>();
configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion()); configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion());
var network = getNetwork(); var network = getNetwork();
configsByLabel.put("BTC Network:", network); 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("My Payment Account:", "");
configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); configsByLabel.put("\tPayment Account Id:", paymentAccount.getId());
configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName());
@ -338,17 +274,12 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
* performs candidate offer filtering, and provides useful log statements. * performs candidate offer filtering, and provides useful log statements.
*/ */
private class TakeCriteria { private class TakeCriteria {
private static final String MARKET_DESCRIPTION = "Buy BTC";
private final BigDecimal currentMarketPrice; private final BigDecimal currentMarketPrice;
@Getter @Getter
private final BigDecimal targetPrice; private final BigDecimal targetPrice;
private final Supplier<String> marketDescription = () -> {
if (isXmr.test(currencyCode))
return "Buy XMR (Sell BTC)";
else
return "Buy BTC";
};
public TakeCriteria() { public TakeCriteria() {
this.currentMarketPrice = getCurrentMarketPrice(currencyCode); this.currentMarketPrice = getCurrentMarketPrice(currencyCode);
this.targetPrice = calcTargetPrice(minMarketPriceMargin, currentMarketPrice, currencyCode); this.targetPrice = calcTargetPrice(minMarketPriceMargin, currentMarketPrice, currencyCode);
@ -367,39 +298,39 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
.filter(isMakerPreferredTradingPeer) .filter(isMakerPreferredTradingPeer)
.filter(o -> isMarginBasedPriceGETargetPrice.test(o, targetPrice) .filter(o -> isMarginBasedPriceGETargetPrice.test(o, targetPrice)
|| isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice)) || isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice))
.filter(isWithinBTCAmountBounds) .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
.findFirst(); .findFirst();
else else
return offers.stream() return offers.stream()
.filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
.filter(o -> isMarginBasedPriceGETargetPrice.test(o, targetPrice) .filter(o -> isMarginBasedPriceGETargetPrice.test(o, targetPrice)
|| isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice)) || isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice))
.filter(isWithinBTCAmountBounds) .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
.findFirst(); .findFirst();
} }
void printCriteriaSummary() { void printCriteriaSummary() {
if (isZero.test(minMarketPriceMargin)) { if (isZero.test(minMarketPriceMargin)) {
log.info("Looking for offers to {}, priced at or higher than the current market price {} {}.", log.info("Looking for offers to {}, priced at or higher than the current market price of {} {}.",
marketDescription.get(), MARKET_DESCRIPTION,
currentMarketPrice, currentMarketPrice,
isXmr.test(currencyCode) ? "BTC" : currencyCode); currencyCode);
} else { } else {
log.info("Looking for offers to {}, priced at or more than {}% {} the current market price {} {}.", log.info("Looking for offers to {}, priced at or higher than {}% {} the current market price of {} {}.",
marketDescription.get(), MARKET_DESCRIPTION,
minMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below". minMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below".
aboveOrBelowMarketPrice.apply(minMarketPriceMargin), aboveOrBelowMinMarketPriceMargin.apply(minMarketPriceMargin),
currentMarketPrice, currentMarketPrice,
isXmr.test(currencyCode) ? "BTC" : currencyCode); currencyCode);
} }
} }
void printOffersAgainstCriteria(List<OfferInfo> offers) { void printOffersAgainstCriteria(List<OfferInfo> offers) {
log.info("Currently available {} offers -- want to take {} offer with price >= {} {}.", log.info("Currently available {} offers -- want to take {} offer with price >= {} {}.",
marketDescription.get(), MARKET_DESCRIPTION,
currencyCode, currencyCode,
targetPrice, targetPrice,
isXmr.test(currencyCode) ? "BTC" : currencyCode); currencyCode);
printOffersSummary(offers); printOffersSummary(offers);
} }
@ -416,23 +347,22 @@ public class TakeBestPricedOfferToBuyBtc extends AbstractBot {
iHavePreferredTradingPeers.get() iHavePreferredTradingPeers.get()
? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO" ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO"
: "N/A"); : "N/A");
var marginPriceLabel = format("Is offer's margin based price (%s) >= bot's target price (%s)?",
offer.getUseMarketBasedPrice() ? offer.getPrice() : "N/A", if (offer.getUseMarketBasedPrice()) {
offer.getUseMarketBasedPrice() ? targetPrice : "N/A"); var marginPriceLabel = format("Is offer's margin based price (%s) >= bot's target price (%s)?",
filterResultsByLabel.put(marginPriceLabel, offer.getPrice() + " " + currencyCode,
offer.getUseMarketBasedPrice() targetPrice + " " + currencyCode);
? isMarginBasedPriceGETargetPrice.test(offer, targetPrice) filterResultsByLabel.put(marginPriceLabel, isMarginBasedPriceGETargetPrice.test(offer, targetPrice));
: "N/A"); } else {
var fixedPriceLabel = format("Is offer's fixed-price (%s) >= bot's target price (%s)?", var fixedPriceLabel = format("Is offer's fixed-price (%s) >= bot's target price (%s)?",
offer.getUseMarketBasedPrice() ? "N/A" : offer.getPrice() + " " + currencyCode, offer.getPrice() + " " + currencyCode,
offer.getUseMarketBasedPrice() ? "N/A" : targetPrice + " " + currencyCode); targetPrice + " " + currencyCode);
filterResultsByLabel.put(fixedPriceLabel, filterResultsByLabel.put(fixedPriceLabel, isFixedPriceGEMinMarketPriceMargin.test(offer, currentMarketPrice));
offer.getUseMarketBasedPrice() }
? "N/A"
: isFixedPriceGEMinMarketPriceMargin.test(offer, currentMarketPrice));
String btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount); String btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount);
filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?", 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:", var title = format("%s offer %s filter results:",
offer.getUseMarketBasedPrice() ? "Margin based" : "Fixed price", offer.getUseMarketBasedPrice() ? "Margin based" : "Fixed price",

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
package bisq.bots;
import bisq.proto.grpc.OfferInfo;
import io.grpc.StatusRuntimeException;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import protobuf.PaymentAccount;
import java.math.BigDecimal;
import java.util.*;
import java.util.function.BiPredicate;
import static bisq.bots.BotUtils.*;
import static java.lang.String.format;
import static java.math.RoundingMode.HALF_UP;
import static protobuf.OfferDirection.SELL;
/**
* 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: <b>TakeBestPricedOfferToBuyXmr.properties</b> (located in project's
* src/main/resources directory). You will need to replace the default values in the configuration file for your
* use cases.
* <p><br/>
* 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
* <a href="https://bisq.network">Bisq Desktop</a> application.
* <p>
* Here is one possible use case:
* <pre>
* 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
* </pre>
* <b>Usage</b>
* <p><br/>
* You must encrypt your wallet password before running this bot. If it is not already encrypted, you can use the CLI:
* <pre>
* $ ./bisq-cli --password=xyz --port=9998 setwalletpassword --wallet-password="be careful"
* </pre>
* 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 <String>`. The bot will prompt you for your wallet-password in the console.
* <p><br/>
* You can pass the '--dryrun=true' option to the program to see what offers your bot <i>would take</i> with a given
* configuration. This will help you avoid taking offers by mistake.
* <pre>
* TakeBestPricedOfferToBuyXmr --password=api-password --port=api-port [--dryrun=true|false]
* </pre>
* 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.
* <pre>
* TakeBestPricedOfferToBuyXmr --password=api-password --port=api-port [--simulate-regtest-payment=true|false]
* </pre>
*
* @see <a href="https://github.com/bisq-network/bisq-api-reference/blob/make-proto-downloader-runnable-from-any-dir/java-examples/src/main/java/bisq/bots/Config.java">bisq.bots.Config.java</a>
*/
@Slf4j
@Getter
public class TakeBestPricedOfferToBuyXmr extends AbstractBot {
// Taker bot's XMR payment account trading currency code.
private static final String CURRENCY_CODE = "XMR";
// Config file: resources/TakeBestPricedOfferToBuyXmr.properties.
private final Properties configFile;
// Taker bot's XMR payment account (if the configured paymentAccountId is valid).
private final PaymentAccount paymentAccount;
// Taker bot's minimum market price margin. A takeable offer's price margin (%) must be >= minMarketPriceMargin (%).
private final BigDecimal minMarketPriceMargin;
// Taker bot's min BTC amount to trade. A takeable offer's amount must be >= minAmount BTC.
private final BigDecimal minAmount;
// Taker bot's max BTC amount to trade. A takeable offer's amount must be <= maxAmount BTC.
private final BigDecimal maxAmount;
// Taker bot's max acceptable transaction fee rate.
private final long maxTxFeeRate;
// Taker bot's trading fee currency code (BSQ or BTC).
private final String bisqTradeFeeCurrency;
// Maximum # of offers to take during one bot session (shut down bot after taking N offers).
private final int maxTakeOffers;
// Offer polling frequency must be > 1000 ms between each getoffers request.
private final long pollingInterval;
// The # of offers taken during the bot session (since startup).
private int numOffersTaken = 0;
public TakeBestPricedOfferToBuyXmr(String[] args) {
super(args);
pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available.
this.configFile = loadConfigFile();
this.paymentAccount = getPaymentAccount(configFile.getProperty("paymentAccountId"));
this.minMarketPriceMargin = new BigDecimal(configFile.getProperty("minMarketPriceMargin"))
.setScale(2, HALF_UP);
this.minAmount = new BigDecimal(configFile.getProperty("minAmount"));
this.maxAmount = new BigDecimal(configFile.getProperty("maxAmount"));
this.maxTxFeeRate = Long.parseLong(configFile.getProperty("maxTxFeeRate"));
this.bisqTradeFeeCurrency = configFile.getProperty("bisqTradeFeeCurrency");
this.maxTakeOffers = Integer.parseInt(configFile.getProperty("maxTakeOffers"));
loadPreferredOnionAddresses.accept(configFile, preferredTradingPeers);
this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval"));
}
/**
* Checks for the most attractive offer to take every {@link #pollingInterval} ms. After {@link #maxTakeOffers}
* are taken, bot will stop the API daemon, then shut itself down, prompting the user to start the desktop UI
* to complete the trade.
*/
@Override
public void run() {
var startTime = new Date().getTime();
validateWalletPassword(walletPassword);
validatePollingInterval(pollingInterval);
validateTradeFeeCurrencyCode(bisqTradeFeeCurrency);
validatePaymentAccount(paymentAccount, CURRENCY_CODE);
printBotConfiguration();
while (!isShutdown) {
if (!isBisqNetworkTxFeeRateLowEnough.test(maxTxFeeRate)) {
runCountdown(log, pollingInterval);
continue;
}
// Get all available and takeable sell BTC for XMR offers, sorted by price descending.
// The list may contain both fixed-price and market price margin based offers.
var offers = getOffers(SELL.name(), CURRENCY_CODE).stream()
.filter(o -> !isAlreadyTaken.test(o))
.toList();
if (offers.isEmpty()) {
log.info("No takeable offers found.");
runCountdown(log, pollingInterval);
continue;
}
// Define criteria for taking an offer, based on conf file.
TakeCriteria takeCriteria = new TakeCriteria();
takeCriteria.printCriteriaSummary();
takeCriteria.printOffersAgainstCriteria(offers);
// Find takeable offer based on criteria.
Optional<OfferInfo> selectedOffer = takeCriteria.findTakeableOffer(offers);
// Try to take the offer, if found, or say 'no offer found' before going to sleep.
selectedOffer.ifPresentOrElse(offer -> takeOffer(takeCriteria, offer),
() -> {
var highestPricedOffer = offers.get(0);
log.info("No acceptable offer found. Closest possible candidate did not pass filters:");
takeCriteria.printOfferAgainstCriteria(highestPricedOffer);
});
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<OfferInfo, BigDecimal> isFixedPriceGEMinMarketPriceMargin =
(offer, currentMarketPrice) -> BotUtils.isFixedPriceGEMinMarketPriceMargin(
offer,
currentMarketPrice,
this.getMinMarketPriceMargin());
private void printBotConfiguration() {
var configsByLabel = new LinkedHashMap<String, Object>();
configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion());
var network = getNetwork();
configsByLabel.put("BTC Network:", network);
configsByLabel.put("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<OfferInfo> findTakeableOffer(List<OfferInfo> offers) {
if (iHavePreferredTradingPeers.get())
return offers.stream()
.filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
.filter(isMakerPreferredTradingPeer)
.filter(o -> isMarginGEMinMarketPriceMargin.test(o, minMarketPriceMargin)
|| isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice))
.filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
.findFirst();
else
return offers.stream()
.filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
.filter(o -> isMarginGEMinMarketPriceMargin.test(o, minMarketPriceMargin)
|| isFixedPriceGEMinMarketPriceMargin.test(o, currentMarketPrice))
.filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
.findFirst();
}
void printCriteriaSummary() {
if (isZero.test(minMarketPriceMargin)) {
log.info("Looking for offers to {}, priced at or higher than the current market price of {} BTC.",
MARKET_DESCRIPTION,
currentMarketPrice);
} else {
log.info("Looking for offers to {}, priced at or higher than {}% {} the current market price of {} BTC.",
MARKET_DESCRIPTION,
minMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below".
aboveOrBelowMinMarketPriceMargin.apply(minMarketPriceMargin),
currentMarketPrice);
}
}
void printOffersAgainstCriteria(List<OfferInfo> offers) {
log.info("Currently available {} offers -- want to take {} offer with price >= {} BTC.",
MARKET_DESCRIPTION,
CURRENCY_CODE,
targetPrice);
printOffersSummary(offers);
}
void printOfferAgainstCriteria(OfferInfo offer) {
printOfferSummary(offer);
var filterResultsByLabel = new LinkedHashMap<String, Object>();
filterResultsByLabel.put("Current Market Price:", currentMarketPrice + " BTC");
filterResultsByLabel.put("Target Price (Min):", targetPrice + " BTC");
filterResultsByLabel.put("Offer Price:", offer.getPrice() + " BTC");
filterResultsByLabel.put("Offer maker used same payment method?",
usesSamePaymentMethod.test(offer, getPaymentAccount()));
filterResultsByLabel.put("Is offer maker a preferred trading peer?",
iHavePreferredTradingPeers.get()
? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO"
: "N/A");
if (offer.getUseMarketBasedPrice()) {
var marginPriceLabel = format("Is offer's 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));
}
}
}

View File

@ -25,48 +25,89 @@ import protobuf.PaymentAccount;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.*; import java.util.*;
import java.util.function.BiPredicate; import java.util.function.BiPredicate;
import java.util.function.Predicate;
import static bisq.bots.BotUtils.*; import static bisq.bots.BotUtils.*;
import static bisq.proto.grpc.GetTradesRequest.Category.CLOSED;
import static java.lang.String.format; import static java.lang.String.format;
import static java.lang.System.exit;
import static java.math.RoundingMode.HALF_UP; import static java.math.RoundingMode.HALF_UP;
import static protobuf.OfferDirection.BUY; 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: <b>TakeBestPricedOfferToSellBsq.properties</b> (located in project's
* src/main/resources directory). You will need to replace the default values in the configuration file for your
* use cases.
* <p><br/>
* 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.
* <p> * <p>
* I'm taking liberties with the classname by not naming it TakeBestPricedOfferToBuyBtcForBsq. * Here is one possible use case:
* <pre>
* 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
* </pre>
* <b>Usage</b>
* <p><br/>
* You must encrypt your wallet password before running this bot. If it is not already encrypted, you can use the CLI:
* <pre>
* $ ./bisq-cli --password=xyz --port=9998 setwalletpassword --wallet-password="be careful"
* </pre>
* 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 <String>`. The bot will prompt you for your wallet-password in the console.
* <p><br/>
* You can pass the '--dryrun=true' option to the program to see what offers your bot <i>would take</i> with a given
* configuration. This will help you avoid taking offers by mistake.
* <pre>
* TakeBestPricedOfferToBuyBsq --password=api-password --port=api-port [--dryrun=true|false]
* </pre>
* <p>
* The '--simulate-regtest-payment=true' option is ignored by this bot. Taking a swap triggers execution of the swap.
*
* @see <a href="https://github.com/bisq-network/bisq-api-reference/blob/make-proto-downloader-runnable-from-any-dir/java-examples/src/main/java/bisq/bots/Config.java">bisq.bots.Config.java</a>
*/ */
@Slf4j @Slf4j
@Getter @Getter
public class TakeBestPricedOfferToSellBsq extends AbstractBot { 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. // Config file: resources/TakeBestPricedOfferToSellBsq.properties.
private final Properties configFile; private final Properties configFile;
// Taker bot's default BSQ Swap payment account. // Taker bot's default BSQ Swap payment account.
private final PaymentAccount paymentAccount; 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 (%). // 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. // 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; private final BigDecimal maxMarketPriceMargin;
// Hard coded 30-day average BSQ trade price, used for development over regtest (ignored when running on mainnet). // Hard coded 30-day average BSQ trade price, used for development over regtest (ignored when running on mainnet).
private final BigDecimal regtest30DayAvgBsqPrice; 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; 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; private final BigDecimal maxAmount;
// Taker bot's max acceptable transaction fee rate. // Taker bot's max acceptable transaction fee rate.
private final long maxTxFeeRate; 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; private final int maxTakeOffers;
// Offer polling frequency must be > 1000 ms between each getoffers request. // Offer polling frequency must be > 1000 ms between each getoffers request.
private final long pollingInterval; 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; private int numOffersTaken = 0;
public TakeBestPricedOfferToSellBsq(String[] args) { 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. pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available.
this.configFile = loadConfigFile(); this.configFile = loadConfigFile();
this.paymentAccount = getBsqSwapPaymentAccount(); this.paymentAccount = getBsqSwapPaymentAccount();
this.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode();
this.maxMarketPriceMargin = new BigDecimal(configFile.getProperty("maxMarketPriceMargin")) this.maxMarketPriceMargin = new BigDecimal(configFile.getProperty("maxMarketPriceMargin"))
.setScale(2, HALF_UP); .setScale(2, HALF_UP);
this.regtest30DayAvgBsqPrice = new BigDecimal(configFile.getProperty("regtest30DayAvgBsqPrice")) this.regtest30DayAvgBsqPrice = new BigDecimal(configFile.getProperty("regtest30DayAvgBsqPrice"))
@ -104,8 +144,9 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot {
continue; continue;
} }
// Get all available and takeable offers, sorted by price ascending. // Get all available buy BTC with BSQ offers, sorted by price ascending.
var offers = getOffers(BUY.name(), currencyCode).stream() // The list contains only fixed-priced offers.
var offers = getOffers(BUY.name(), CURRENCY_CODE).stream()
.filter(o -> !isAlreadyTaken.test(o)) .filter(o -> !isAlreadyTaken.test(o))
.toList(); .toList();
@ -130,7 +171,6 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot {
takeCriteria.printOfferAgainstCriteria(cheapestOffer); takeCriteria.printOfferAgainstCriteria(cheapestOffer);
}); });
printDryRunProgress();
runCountdown(log, pollingInterval); runCountdown(log, pollingInterval);
pingDaemon(startTime); pingDaemon(startTime);
} }
@ -139,17 +179,21 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot {
private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) {
log.info("Will attempt to take offer '{}'.", offer.getId()); log.info("Will attempt to take offer '{}'.", offer.getId());
takeCriteria.printOfferAgainstCriteria(offer); 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) { if (isDryRun) {
addToOffersTaken(offer); addToOffersTaken(offer);
numOffersTaken++; numOffersTaken++;
maybeShutdownAfterSuccessfulSwap();
} else { } 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 { try {
unlockWallet(walletPassword, 600);
printBTCBalances("BTC Balances Before Swap Execution"); printBTCBalances("BTC Balances Before Swap Execution");
printBSQBalances("BSQ 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"); printBSQBalances("BSQ Balances After Swap Execution");
numOffersTaken++; numOffersTaken++;
maybeShutdownAfterSuccessfulSwap();
} catch (NonFatalException nonFatalException) { } catch (NonFatalException nonFatalException) {
handleNonFatalException(nonFatalException); handleNonFatalException(nonFatalException, pollingInterval);
} catch (StatusRuntimeException fatalException) { } catch (StatusRuntimeException fatalException) {
handleFatalException(fatalException); handleFatalBsqSwapException(fatalException);
} }
} }
maybeShutdownAfterSuccessfulSwap(numOffersTaken, maxTakeOffers);
} }
private void printBotConfiguration() { private void printBotConfiguration() {
@ -174,12 +218,13 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot {
configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion()); configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion());
var network = getNetwork(); var network = getNetwork();
configsByLabel.put("BTC Network:", network); configsByLabel.put("BTC Network:", network);
configsByLabel.put("Dry Run?", isDryRun ? "YES" : "NO");
var isMainnet = network.equalsIgnoreCase("mainnet"); var isMainnet = network.equalsIgnoreCase("mainnet");
var mainnet30DayAvgBsqPrice = isMainnet ? get30DayAvgBsqPriceInBtc() : null; var mainnet30DayAvgBsqPrice = isMainnet ? get30DayAvgBsqPriceInBtc() : null;
configsByLabel.put("My Payment Account:", ""); configsByLabel.put("My Payment Account:", "");
configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); configsByLabel.put("\tPayment Account Id:", paymentAccount.getId());
configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName());
configsByLabel.put("\tCurrency Code:", currencyCode); configsByLabel.put("\tCurrency Code:", CURRENCY_CODE);
configsByLabel.put("Trading Rules:", ""); configsByLabel.put("Trading Rules:", "");
configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers); configsByLabel.put("\tMax # of offers bot can take:", maxTakeOffers);
configsByLabel.put("\tMax Tx Fee Rate:", maxTxFeeRate + " sats/byte"); 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.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 * 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. * fixed-priced offer if the price is <= {@link #maxMarketPriceMargin} (%) of the current market price.
@ -257,13 +255,6 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot {
currentMarketPrice, currentMarketPrice,
getMaxMarketPriceMargin()); getMaxMarketPriceMargin());
/**
* Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries).
* TODO API's takeoffer needs to support taking offer's minAmount.
*/
protected final Predicate<OfferInfo> isWithinBTCAmountBounds = (offer) ->
BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
public static void main(String[] args) { public static void main(String[] args) {
TakeBestPricedOfferToSellBsq bot = new TakeBestPricedOfferToSellBsq(args); TakeBestPricedOfferToSellBsq bot = new TakeBestPricedOfferToSellBsq(args);
bot.run(); bot.run();
@ -285,7 +276,6 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot {
this.targetPrice = calcTargetBsqPrice(maxMarketPriceMargin, avgBsqPrice); this.targetPrice = calcTargetBsqPrice(maxMarketPriceMargin, avgBsqPrice);
} }
/** /**
* Returns the lowest priced offer passing the filters, or Optional.empty() if not found. * 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. * 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(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
.filter(isMakerPreferredTradingPeer) .filter(isMakerPreferredTradingPeer)
.filter(o -> isFixedPriceLEMaxMarketPriceMargin.test(o, avgBsqPrice)) .filter(o -> isFixedPriceLEMaxMarketPriceMargin.test(o, avgBsqPrice))
.filter(isWithinBTCAmountBounds) .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
.findFirst(); .findFirst();
else else
return offers.stream() return offers.stream()
.filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
.filter(o -> isFixedPriceLEMaxMarketPriceMargin.test(o, avgBsqPrice)) .filter(o -> isFixedPriceLEMaxMarketPriceMargin.test(o, avgBsqPrice))
.filter(isWithinBTCAmountBounds) .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
.findFirst(); .findFirst();
} }
void printCriteriaSummary() { void printCriteriaSummary() {
if (isZero.test(maxMarketPriceMargin)) { 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.", + " the 30-day average BSQ trade price of {} BTC.",
MARKET_DESCRIPTION, MARKET_DESCRIPTION,
avgBsqPrice); avgBsqPrice);
} else { } 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.", + " {}% {} the 30-day average BSQ trade price of {} BTC.",
MARKET_DESCRIPTION, MARKET_DESCRIPTION,
maxMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below". maxMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below".
aboveOrBelowMarketPrice.apply(maxMarketPriceMargin), aboveOrBelowMaxMarketPriceMargin.apply(maxMarketPriceMargin),
avgBsqPrice); avgBsqPrice);
} }
} }
@ -336,7 +326,7 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot {
var filterResultsByLabel = new LinkedHashMap<String, Object>(); var filterResultsByLabel = new LinkedHashMap<String, Object>();
filterResultsByLabel.put("30-day Avg BSQ trade price:", avgBsqPrice + " BTC"); 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 Price:", offer.getPrice() + " BTC");
filterResultsByLabel.put("Offer maker used same payment method?", filterResultsByLabel.put("Offer maker used same payment method?",
usesSamePaymentMethod.test(offer, getPaymentAccount())); usesSamePaymentMethod.test(offer, getPaymentAccount()));
@ -344,13 +334,13 @@ public class TakeBestPricedOfferToSellBsq extends AbstractBot {
iHavePreferredTradingPeers.get() iHavePreferredTradingPeers.get()
? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO" ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO"
: "N/A"); : "N/A");
var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's minimum price (%s)?", var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's maximum price of (%s)?",
offer.getPrice() + " " + currencyCode, offer.getPrice() + " BTC",
targetPrice + " " + currencyCode); targetPrice + " BTC");
filterResultsByLabel.put(fixedPriceLabel, isFixedPriceLEMaxMarketPriceMargin.test(offer, avgBsqPrice)); filterResultsByLabel.put(fixedPriceLabel, isFixedPriceLEMaxMarketPriceMargin.test(offer, avgBsqPrice));
var btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount); var btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount);
filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?", 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()); var title = format("Fixed price BSQ swap offer %s filter results:", offer.getId());
log.info(toTable.apply(title, filterResultsByLabel)); log.info(toTable.apply(title, filterResultsByLabel));

View File

@ -25,59 +25,64 @@ import protobuf.PaymentAccount;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.*; import java.util.*;
import java.util.function.BiPredicate; import java.util.function.BiPredicate;
import java.util.function.Predicate;
import java.util.function.Supplier;
import static bisq.bots.BotUtils.*; import static bisq.bots.BotUtils.*;
import static java.lang.String.format; import static java.lang.String.format;
import static java.lang.System.exit;
import static java.math.RoundingMode.HALF_UP; import static java.math.RoundingMode.HALF_UP;
import static protobuf.OfferDirection.BUY;
import static protobuf.OfferDirection.SELL; import static protobuf.OfferDirection.SELL;
/** /**
* The TakeBestPricedOfferToSellBtc bot waits for attractively priced SELL BTC offers to appear, takes the offers * This bot's general use case is to buy BTC with fiat at a low fiat price. It periodically checks the
* (up to a maximum of configured {@link #maxTakeOffers}), then shuts down both the API daemon and itself (the bot), * Sell BTC market, and takes a configured maximum number of offers to sell BTC to you according to criteria you
* to allow the user to start the desktop UI application and complete the trades. * define in the bot's configuration file: <b>TakeBestPricedOfferToSellBtc.properties</b> (located in project's
* src/main/resources directory). You will need to replace the default values in the configuration file for your
* use cases.
* <p><br/>
* 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 <a href="https://bisq.network">Bisq Desktop</a> application.
* <p> * <p>
* The benefit this bot provides is freeing up the user time spent watching the offer book in the UI, waiting for the * Here is one possible use case:
* 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.
* <p>
* 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.
* <p>
* 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.
* <p>
* One possible use case for this bot is buy BTC with GBP:
* <pre> * <pre>
* Take a "Faster Payment (Santander)" offer to sell BTC for GBP at or below current market price if: * Take 4 "Faster Payment" offers to sell BTC for GBP, priced no higher than 1.00% above the current market
* the offer maker is a preferred trading peer, * price if:
* 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. * 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
* </pre> * </pre>
* <p> * <b>Usage</b>
* Another possible use case for this bot is to sell BTC for XMR. (We might say "buy XMR with BTC", but we need to * <p><br/>
* remember that all Bisq offers are for buying or selling BTC.) * You must encrypt your wallet password before running this bot. If it is not already encrypted, you can use the CLI:
* <pre> * <pre>
* Take an offer to sell BTC for XMR at or below current market price if: * $ ./bisq-cli --password=xyz --port=9998 setwalletpassword --wallet-password="be careful"
* the offer maker is a preferred trading peer,
* and the offer's BTC amount is between 0.50 and 1.00 BTC,
* and the current transaction mining fee rate is below 15 sats / byte.
* </pre> * </pre>
* <p> * 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 <String>`. The bot will prompt you for your wallet-password in the console.
* <p><br/>
* You can pass the '--dryrun=true' option to the program to see what offers your bot <i>would take</i> with a given
* configuration. This will help you avoid taking offers by mistake.
* <pre> * <pre>
* Usage: TakeBestPricedOfferToSellBtc --password=api-password --port=api-port \ * TakeBestPricedOfferToBuyBtc --password=api-password --port=api-port [--dryrun=true|false]
* [--conf=take-best-priced-offer-to-sell-btc.conf] \
* [--dryrun=true|false]
* [--simulate-regtest-payment=true|false]
* </pre> * </pre>
* 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.
* <pre>
* TakeBestPricedOfferToBuyBtc --password=api-password --port=api-port [--simulate-regtest-payment=true|false]
* </pre>
*
* @see <a href="https://github.com/bisq-network/bisq-api-reference/blob/make-proto-downloader-runnable-from-any-dir/java-examples/src/main/java/bisq/bots/Config.java">bisq.bots.Config.java</a>
*/ */
@Slf4j @Slf4j
@Getter @Getter
@ -91,21 +96,21 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot {
private final String currencyCode; private final String currencyCode;
// Taker bot's max market price margin. A takeable offer's price margin (%) must be <= maxMarketPriceMargin (%). // Taker bot's max market price margin. A takeable offer's price margin (%) must be <= maxMarketPriceMargin (%).
private final BigDecimal 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; 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; private final BigDecimal maxAmount;
// Taker bot's max acceptable transaction fee rate. // Taker bot's max acceptable transaction fee rate.
private final long maxTxFeeRate; private final long maxTxFeeRate;
// Taker bot's trading fee currency code (BSQ or BTC). // Taker bot's trading fee currency code (BSQ or BTC).
private final String bisqTradeFeeCurrency; 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; private final int maxTakeOffers;
// Offer polling frequency must be > 1000 ms between each getoffers request. // Offer polling frequency must be > 1000 ms between each getoffers request.
private final long pollingInterval; 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; private int numOffersTaken = 0;
public TakeBestPricedOfferToSellBtc(String[] args) { public TakeBestPricedOfferToSellBtc(String[] args) {
@ -145,12 +150,9 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot {
continue; continue;
} }
// Taker bot's getOffers(direction) request param. For fiat offers, is SELL (BTC), for XMR offers, is BUY (BTC). // Get all available and takeable sell BTC offers, sorted by price ascending.
String offerDirection = isXmr.test(currencyCode) ? BUY.name() : SELL.name();
// Get all available and takeable offers, sorted by price ascending.
// The list contains both fixed-price and market price margin based offers. // 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)) .filter(o -> !isAlreadyTaken.test(o))
.toList(); .toList();
@ -175,7 +177,6 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot {
takeCriteria.printOfferAgainstCriteria(cheapestOffer); takeCriteria.printOfferAgainstCriteria(cheapestOffer);
}); });
printDryRunProgress();
runCountdown(log, pollingInterval); runCountdown(log, pollingInterval);
pingDaemon(startTime); pingDaemon(startTime);
} }
@ -189,16 +190,21 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot {
private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) { private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) {
log.info("Will attempt to take offer '{}'.", offer.getId()); log.info("Will attempt to take offer '{}'.", offer.getId());
takeCriteria.printOfferAgainstCriteria(offer); 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) { if (isDryRun) {
addToOffersTaken(offer); addToOffersTaken(offer);
numOffersTaken++; numOffersTaken++;
maybeShutdownAfterSuccessfulTradeCreation();
} else { } 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 { try {
unlockWallet(walletPassword, 600);
printBTCBalances("BTC Balances Before Take Offer Attempt"); printBTCBalances("BTC Balances Before Take Offer Attempt");
// Blocks until new trade is prepared, or times out. // Blocks until new trade is prepared, or times out.
takeV1ProtocolOffer(offer, paymentAccount, bisqTradeFeeCurrency, pollingInterval); takeV1ProtocolOffer(offer, paymentAccount, bisqTradeFeeCurrency, pollingInterval);
@ -213,79 +219,13 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot {
printBTCBalances("BTC Balances After Simulated Trade Completion"); printBTCBalances("BTC Balances After Simulated Trade Completion");
} }
numOffersTaken++; numOffersTaken++;
maybeShutdownAfterSuccessfulTradeCreation();
} catch (NonFatalException nonFatalException) { } catch (NonFatalException nonFatalException) {
handleNonFatalException(nonFatalException); handleNonFatalException(nonFatalException, pollingInterval);
} catch (StatusRuntimeException fatalException) { } catch (StatusRuntimeException fatalException) {
handleFatalException(fatalException); shutdownAfterTakeOfferFailure(fatalException);
} }
} }
} maybeShutdownAfterSuccessfulTradeCreation(numOffersTaken, maxTakeOffers);
/**
* 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.");
} }
/** /**
@ -298,18 +238,13 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot {
currentMarketPrice, currentMarketPrice,
getMaxMarketPriceMargin()); getMaxMarketPriceMargin());
/**
* Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries).
* TODO API's takeoffer needs to support taking offer's minAmount.
*/
protected final Predicate<OfferInfo> isWithinBTCAmountBounds = (offer) ->
BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
private void printBotConfiguration() { private void printBotConfiguration() {
var configsByLabel = new LinkedHashMap<String, Object>(); var configsByLabel = new LinkedHashMap<String, Object>();
configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion()); configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion());
var network = getNetwork(); var network = getNetwork();
configsByLabel.put("BTC Network:", network); 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("My Payment Account:", "");
configsByLabel.put("\tPayment Account Id:", paymentAccount.getId()); configsByLabel.put("\tPayment Account Id:", paymentAccount.getId());
configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName()); configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName());
@ -339,17 +274,12 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot {
* performs candidate offer filtering, and provides useful log statements. * performs candidate offer filtering, and provides useful log statements.
*/ */
private class TakeCriteria { private class TakeCriteria {
private static final String MARKET_DESCRIPTION = "Sell BTC";
private final BigDecimal currentMarketPrice; private final BigDecimal currentMarketPrice;
@Getter @Getter
private final BigDecimal targetPrice; private final BigDecimal targetPrice;
private final Supplier<String> marketDescription = () -> {
if (isXmr.test(currencyCode))
return "Sell XMR (Buy BTC)";
else
return "Sell BTC";
};
public TakeCriteria() { public TakeCriteria() {
this.currentMarketPrice = getCurrentMarketPrice(currencyCode); this.currentMarketPrice = getCurrentMarketPrice(currencyCode);
this.targetPrice = calcTargetPrice(maxMarketPriceMargin, currentMarketPrice, currencyCode); this.targetPrice = calcTargetPrice(maxMarketPriceMargin, currentMarketPrice, currencyCode);
@ -368,39 +298,39 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot {
.filter(isMakerPreferredTradingPeer) .filter(isMakerPreferredTradingPeer)
.filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin) .filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin)
|| isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice)) || isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice))
.filter(isWithinBTCAmountBounds) .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
.findFirst(); .findFirst();
else else
return offers.stream() return offers.stream()
.filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount())) .filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
.filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin) .filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin)
|| isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice)) || isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice))
.filter(isWithinBTCAmountBounds) .filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
.findFirst(); .findFirst();
} }
void printCriteriaSummary() { void printCriteriaSummary() {
if (isZero.test(maxMarketPriceMargin)) { if (isZero.test(maxMarketPriceMargin)) {
log.info("Looking for offers to {}, priced at or lower than the current market price {} {}.", log.info("Looking for offers to {}, priced at or lower than the current market price of {} {}.",
marketDescription.get(), MARKET_DESCRIPTION,
currentMarketPrice, currentMarketPrice,
isXmr.test(currencyCode) ? "BTC" : currencyCode); currencyCode);
} else { } else {
log.info("Looking for offers to {}, priced at or less than {}% {} the current market price {} {}.", log.info("Looking for offers to {}, priced at or lower than {}% {} the current market price of {} {}.",
marketDescription.get(), MARKET_DESCRIPTION,
maxMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below". maxMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below".
aboveOrBelowMarketPrice.apply(maxMarketPriceMargin), aboveOrBelowMaxMarketPriceMargin.apply(maxMarketPriceMargin),
currentMarketPrice, currentMarketPrice,
isXmr.test(currencyCode) ? "BTC" : currencyCode); currencyCode);
} }
} }
void printOffersAgainstCriteria(List<OfferInfo> offers) { void printOffersAgainstCriteria(List<OfferInfo> offers) {
log.info("Currently available {} offers -- want to take {} offer with price <= {} {}.", log.info("Currently available {} offers -- want to take {} offer with price <= {} {}.",
marketDescription.get(), MARKET_DESCRIPTION,
currencyCode, currencyCode,
targetPrice, targetPrice,
isXmr.test(currencyCode) ? "BTC" : currencyCode); currencyCode);
printOffersSummary(offers); printOffersSummary(offers);
} }
@ -417,23 +347,22 @@ public class TakeBestPricedOfferToSellBtc extends AbstractBot {
iHavePreferredTradingPeers.get() iHavePreferredTradingPeers.get()
? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO" ? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO"
: "N/A"); : "N/A");
var marginPriceLabel = format("Is offer's price margin (%s%%) <= bot's max market price margin (%s%%)?",
offer.getMarketPriceMarginPct(), if (offer.getUseMarketBasedPrice()) {
maxMarketPriceMargin); var marginPriceLabel = format("Is offer's margin based price (%s) <= bot's target price (%s)?",
filterResultsByLabel.put(marginPriceLabel, offer.getPrice() + " " + currencyCode,
offer.getUseMarketBasedPrice() targetPrice + " " + currencyCode);
? isMarginLEMaxMarketPriceMargin.test(offer, maxMarketPriceMargin) filterResultsByLabel.put(marginPriceLabel, isMarginLEMaxMarketPriceMargin.test(offer, maxMarketPriceMargin));
: "N/A"); } else {
var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's target price (%s)?", var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's target price (%s)?",
offer.getUseMarketBasedPrice() ? "N/A" : offer.getPrice() + " " + currencyCode, offer.getPrice() + " " + currencyCode,
offer.getUseMarketBasedPrice() ? "N/A" : targetPrice + " " + currencyCode); targetPrice + " " + currencyCode);
filterResultsByLabel.put(fixedPriceLabel, filterResultsByLabel.put(fixedPriceLabel, isFixedPriceLEMaxMarketPriceMargin.test(offer, currentMarketPrice));
offer.getUseMarketBasedPrice() }
? "N/A"
: isFixedPriceLEMaxMarketPriceMargin.test(offer, currentMarketPrice));
String btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount); String btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount);
filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?", 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:", var title = format("%s offer %s filter results:",
offer.getUseMarketBasedPrice() ? "Margin based" : "Fixed price", offer.getUseMarketBasedPrice() ? "Margin based" : "Fixed price",

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
package bisq.bots;
import bisq.proto.grpc.OfferInfo;
import io.grpc.StatusRuntimeException;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import protobuf.PaymentAccount;
import java.math.BigDecimal;
import java.util.*;
import java.util.function.BiPredicate;
import static bisq.bots.BotUtils.*;
import static java.lang.String.format;
import static java.math.RoundingMode.HALF_UP;
import static protobuf.OfferDirection.BUY;
/**
* 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: <b>TakeBestPricedOfferToSellXmr.properties</b> (located in project's
* src/main/resources directory). You will need to replace the default values in the configuration file for your
* use cases.
* <p><br/>
* 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 <a href="https://bisq.network">Bisq Desktop</a> application.
* <p>
* Here is one possible use case:
* <pre>
* 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
* </pre>
* <b>Usage</b>
* <p><br/>
* You must encrypt your wallet password before running this bot. If it is not already encrypted, you can use the CLI:
* <pre>
* $ ./bisq-cli --password=xyz --port=9998 setwalletpassword --wallet-password="be careful"
* </pre>
* 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 <String>`. The bot will prompt you for your wallet-password in the console.
* <p><br/>
* You can pass the '--dryrun=true' option to the program to see what offers your bot <i>would take</i> with a given
* configuration. This will help you avoid taking offers by mistake.
* <pre>
* TakeBestPricedOfferToSellXmr --password=api-password --port=api-port [--dryrun=true|false]
* </pre>
* 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.
* <pre>
* TakeBestPricedOfferToSellXmr --password=api-password --port=api-port [--simulate-regtest-payment=true|false]
* </pre>
*
* @see <a href="https://github.com/bisq-network/bisq-api-reference/blob/make-proto-downloader-runnable-from-any-dir/java-examples/src/main/java/bisq/bots/Config.java">bisq.bots.Config.java</a>
*/
@Slf4j
@Getter
public class TakeBestPricedOfferToSellXmr extends AbstractBot {
// Taker bot's XMR payment account trading currency code.
private static final String CURRENCY_CODE = "XMR";
// Config file: resources/TakeBestPricedOfferToSellXmr.properties.
private final Properties configFile;
// Taker bot's XMR payment account (if the configured paymentAccountId is valid).
private final PaymentAccount paymentAccount;
// Taker bot's maximum market price margin. A takeable offer's price margin (%) must be <= maxMarketPriceMargin (%).
private final BigDecimal maxMarketPriceMargin;
// Taker bot's min BTC amount to trade. A takeable offer's amount must be >= minAmount BTC.
private final BigDecimal minAmount;
// Taker bot's max BTC amount to trade. A takeable offer's amount must be <= maxAmount BTC.
private final BigDecimal maxAmount;
// Taker bot's max acceptable transaction fee rate.
private final long maxTxFeeRate;
// Taker bot's trading fee currency code (BSQ or BTC).
private final String bisqTradeFeeCurrency;
// Maximum # of offers to take during one bot session (shut down bot after taking N offers).
private final int maxTakeOffers;
// Offer polling frequency must be > 1000 ms between each getoffers request.
private final long pollingInterval;
// The # of offers taken during the bot session (since startup).
private int numOffersTaken = 0;
public TakeBestPricedOfferToSellXmr(String[] args) {
super(args);
pingDaemon(new Date().getTime()); // Shut down now if API daemon is not available.
this.configFile = loadConfigFile();
this.paymentAccount = getPaymentAccount(configFile.getProperty("paymentAccountId"));
this.maxMarketPriceMargin = new BigDecimal(configFile.getProperty("maxMarketPriceMargin"))
.setScale(2, HALF_UP);
this.minAmount = new BigDecimal(configFile.getProperty("minAmount"));
this.maxAmount = new BigDecimal(configFile.getProperty("maxAmount"));
this.maxTxFeeRate = Long.parseLong(configFile.getProperty("maxTxFeeRate"));
this.bisqTradeFeeCurrency = configFile.getProperty("bisqTradeFeeCurrency");
this.maxTakeOffers = Integer.parseInt(configFile.getProperty("maxTakeOffers"));
loadPreferredOnionAddresses.accept(configFile, preferredTradingPeers);
this.pollingInterval = Long.parseLong(configFile.getProperty("pollingInterval"));
}
/**
* Checks for the most attractive offer to take every {@link #pollingInterval} ms. After {@link #maxTakeOffers}
* are taken, bot will stop the API daemon, then shut itself down, prompting the user to start the desktop UI
* to complete the trade.
*/
@Override
public void run() {
var startTime = new Date().getTime();
validateWalletPassword(walletPassword);
validatePollingInterval(pollingInterval);
validateTradeFeeCurrencyCode(bisqTradeFeeCurrency);
validatePaymentAccount(paymentAccount, CURRENCY_CODE);
printBotConfiguration();
while (!isShutdown) {
if (!isBisqNetworkTxFeeRateLowEnough.test(maxTxFeeRate)) {
runCountdown(log, pollingInterval);
continue;
}
// Get all available and takeable buy BTC for XMR offers, sorted by price ascending.
// The list may contain both fixed-price and market price margin based offers.
var offers = getOffers(BUY.name(), CURRENCY_CODE).stream()
.filter(o -> !isAlreadyTaken.test(o))
.toList();
if (offers.isEmpty()) {
log.info("No takeable offers found.");
runCountdown(log, pollingInterval);
continue;
}
// Define criteria for taking an offer, based on conf file.
TakeCriteria takeCriteria = new TakeCriteria();
takeCriteria.printCriteriaSummary();
takeCriteria.printOffersAgainstCriteria(offers);
// Find takeable offer based on criteria.
Optional<OfferInfo> selectedOffer = takeCriteria.findTakeableOffer(offers);
// Try to take the offer, if found, or say 'no offer found' before going to sleep.
selectedOffer.ifPresentOrElse(offer -> takeOffer(takeCriteria, offer),
() -> {
var cheapestOffer = offers.get(0);
log.info("No acceptable offer found. Closest possible candidate did not pass filters:");
takeCriteria.printOfferAgainstCriteria(cheapestOffer);
});
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<OfferInfo, BigDecimal> isFixedPriceLEMaxMarketPriceMargin =
(offer, currentMarketPrice) -> BotUtils.isFixedPriceLEMaxMarketPriceMargin(
offer,
currentMarketPrice,
this.getMaxMarketPriceMargin());
private void printBotConfiguration() {
var configsByLabel = new LinkedHashMap<String, Object>();
configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion());
var network = getNetwork();
configsByLabel.put("BTC Network:", network);
configsByLabel.put("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<OfferInfo> findTakeableOffer(List<OfferInfo> offers) {
if (iHavePreferredTradingPeers.get())
return offers.stream()
.filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
.filter(isMakerPreferredTradingPeer)
.filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin)
|| isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice))
.filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
.findFirst();
else
return offers.stream()
.filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
.filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin)
|| isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice))
.filter(o -> isWithinBTCAmountBounds(o, getMinAmount(), getMaxAmount()))
.findFirst();
}
void printCriteriaSummary() {
if (isZero.test(maxMarketPriceMargin)) {
log.info("Looking for offers to {}, priced at or lower than the current market price of {} BTC.",
MARKET_DESCRIPTION,
currentMarketPrice);
} else {
log.info("Looking for offers to {}, priced at or lower than {}% {} the current market price of {} BTC.",
MARKET_DESCRIPTION,
maxMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below".
aboveOrBelowMaxMarketPriceMargin.apply(maxMarketPriceMargin),
currentMarketPrice);
}
}
void printOffersAgainstCriteria(List<OfferInfo> offers) {
log.info("Currently available {} offers -- want to take {} offer with price <= {} BTC.",
MARKET_DESCRIPTION,
CURRENCY_CODE,
targetPrice);
printOffersSummary(offers);
}
void printOfferAgainstCriteria(OfferInfo offer) {
printOfferSummary(offer);
var filterResultsByLabel = new LinkedHashMap<String, Object>();
filterResultsByLabel.put("Current Market Price:", currentMarketPrice + " " + CURRENCY_CODE);
filterResultsByLabel.put("Target Price (Max):", targetPrice + " " + CURRENCY_CODE);
filterResultsByLabel.put("Offer Price:", offer.getPrice() + " " + CURRENCY_CODE);
filterResultsByLabel.put("Offer maker used same payment method?",
usesSamePaymentMethod.test(offer, getPaymentAccount()));
filterResultsByLabel.put("Is offer maker a preferred trading peer?",
iHavePreferredTradingPeers.get()
? isMakerPreferredTradingPeer.test(offer) ? "YES" : "NO"
: "N/A");
if (offer.getUseMarketBasedPrice()) {
var marginPriceLabel = format("Is offer's 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));
}
}
}

View File

@ -1,12 +1,12 @@
# Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon). # 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. # 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. # 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 minMarketPriceMargin=0.00
# #
# Hard coded 30-day average BSQ trade price, used for development over regtest. # 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. # Taker bot's min BTC amount to sell. The candidate SELL BTC offer's amount must be >= minAmount BTC.
minAmount=0.01 minAmount=0.01
@ -19,8 +19,9 @@ maxAmount=0.90
maxTxFeeRate=25 maxTxFeeRate=25
# #
# Taker bot's list of preferred trading peers (their onion addresses). # 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. # If you do not want to constrict trading to preferred peers, comment this line out with a '#' character.
preferredTradingPeers=localhost:8888 preferredTradingPeers=localhost:8888
# #
# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. # Offer polling frequency must be >= 1s (1000ms) between each getoffers request.
pollingInterval=30000 pollingInterval=60000

View File

@ -1,13 +1,11 @@
#
# Maximum # of offers to take during one bot session. When reached, bot will shut down API daemon then itself. # 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. # 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. # Taker bot's min market price margin. A candidate BUY BTC offer's price margin must be >= minMarketPriceMargin.
# minMarketPriceMargin=-5.00
minMarketPriceMargin=0
# #
# Taker bot's min BTC amount to sell. The candidate BUY offer's amount must be >= minAmount BTC. # Taker bot's min BTC amount to sell. The candidate BUY offer's amount must be >= minAmount BTC.
minAmount=0.01 minAmount=0.01
@ -23,6 +21,7 @@ maxTxFeeRate=25
bisqTradeFeeCurrency=BSQ bisqTradeFeeCurrency=BSQ
# #
# Taker bot's list of preferred trading peers (their onion addresses). # 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. # If you do not want to constrict trading to preferred peers, comment this line out with a '#' character.
preferredTradingPeers=localhost:8888, \ preferredTradingPeers=localhost:8888, \
nysf2pknaaxfh26k42ego5mnfzpbozyi3nuoxdu745unvva4pvywffyd.onion:9999, \ nysf2pknaaxfh26k42ego5mnfzpbozyi3nuoxdu745unvva4pvywffyd.onion:9999, \
@ -35,4 +34,4 @@ preferredTradingPeers=localhost:8888, \
x6x2o3m6rxhkfuf2v6lalbharf3whwvkts5rdn3jkhgieqvnq6mvdfyd.onion:9999 x6x2o3m6rxhkfuf2v6lalbharf3whwvkts5rdn3jkhgieqvnq6mvdfyd.onion:9999
# #
# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. # Offer polling frequency must be >= 1s (1000ms) between each getoffers request.
pollingInterval=20000 pollingInterval=60000

View File

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

View File

@ -1,12 +1,12 @@
# Maximum # of offers to take during one bot session. When reached, bot will shut down (but not the API daemon). # 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. # 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. # 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. # 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. # Taker bot's min BTC amount to buy. The candidate BUY BTC offer's amount must be >= minAmount BTC.
minAmount=0.01 minAmount=0.01
@ -19,8 +19,9 @@ maxAmount=0.90
maxTxFeeRate=25 maxTxFeeRate=25
# #
# Taker bot's list of preferred trading peers (their onion addresses). # 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. # If you do not want to constrict trading to preferred peers, comment this line out with a '#' character.
preferredTradingPeers=localhost:8888 preferredTradingPeers=localhost:8888
# #
# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. # Offer polling frequency must be >= 1s (1000ms) between each getoffers request.
pollingInterval=30000 pollingInterval=60000

View File

@ -1,12 +1,12 @@
# #
# Maximum # of offers to take during one bot session. When reached, bot will shut down API daemon then itself. # 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. # 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. # 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. # Taker bot's min BTC amount to buy. The candidate SELL offer's amount must be >= minAmount BTC.
minAmount=0.01 minAmount=0.01
@ -16,12 +16,13 @@ maxAmount=0.50
# #
# Taker bot's max acceptable transaction fee rate (sats / byte). # Taker bot's max acceptable transaction fee rate (sats / byte).
# Regtest fee rates are from https://price.bisq.wiz.biz/getFees # Regtest fee rates are from https://price.bisq.wiz.biz/getFees
maxTxFeeRate=100 maxTxFeeRate=20
# #
# Bisq trade fee currency code (BSQ or BTC). # Bisq trade fee currency code (BSQ or BTC).
bisqTradeFeeCurrency=BSQ bisqTradeFeeCurrency=BSQ
# #
# Taker bot's list of preferred trading peers (their onion addresses). # 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. # If you do not want to constrict trading to preferred peers, comment this line out with a '#' character.
preferredTradingPeers=localhost:8888, \ preferredTradingPeers=localhost:8888, \
nysf2pknaaxfh26k42ego5mnfzpbozyi3nuoxdu745unvva4pvywffyd.onion:9999, \ nysf2pknaaxfh26k42ego5mnfzpbozyi3nuoxdu745unvva4pvywffyd.onion:9999, \
@ -34,4 +35,4 @@ preferredTradingPeers=localhost:8888, \
x6x2o3m6rxhkfuf2v6lalbharf3whwvkts5rdn3jkhgieqvnq6mvdfyd.onion:9999 x6x2o3m6rxhkfuf2v6lalbharf3whwvkts5rdn3jkhgieqvnq6mvdfyd.onion:9999
# #
# Offer polling frequency must be >= 1s (1000ms) between each getoffers request. # Offer polling frequency must be >= 1s (1000ms) between each getoffers request.
pollingInterval=20000 pollingInterval=60000

View File

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

View File

@ -3,8 +3,37 @@
This subproject contains Python3 classes demonstrating API gRPC method calls, and some sample bots. This subproject contains Python3 classes demonstrating API gRPC method calls, and some sample bots.
Each class in Each class in
the [bisq.rpccalls](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples/bisq/rpccalls) package is the [bisq.rpccalls](https://github.com/bisq-network/bisq-api-reference/tree/main/python-examples/bisq/rpccalls) package
named for the RPC method call being demonstrated. 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. 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).