forked from ghubstan/bisq-api-reference
-
Notifications
You must be signed in to change notification settings - Fork 1
/
TakeBestPricedOfferToSellBtc.java
440 lines (402 loc) · 21.4 KB
/
TakeBestPricedOfferToSellBtc.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
/*
* 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 java.util.function.Predicate;
import java.util.function.Supplier;
import static bisq.bots.BotUtils.*;
import static java.lang.String.format;
import static java.lang.System.exit;
import static java.math.RoundingMode.HALF_UP;
import static protobuf.OfferDirection.BUY;
import static protobuf.OfferDirection.SELL;
/**
* The TakeBestPricedOfferToSellBtc bot waits for attractively priced SELL BTC offers to appear, takes the offers
* (up to a maximum of configured {@link #maxTakeOffers}, then shuts down both the API daemon and itself (the bot),
* to allow the user to start the desktop UI application and complete the trades.
* <p>
* The benefit this bot provides is freeing up the user time spent watching the offer book in the UI, waiting for the
* right offer to take. Low-priced offers are taken relatively quickly; this bot increases the chance of beating
* the other nodes at taking the offer.
* <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>
* Take a "Faster Payment (Santander)" offer to sell BTC for GBP at or below current market price if:
* the offer maker is a preferred trading peer,
* and the offer's BTC amount is between 0.10 and 0.25 BTC,
* and the current transaction mining fee rate is below 20 sats / byte.
* </pre>
* <p>
* Another possible use case for this bot is to sell BTC for XMR. (We might say "buy XMR with BTC", but we need to
* remember that all Bisq offers are for buying or selling BTC.)
* <pre>
* Take an offer to sell BTC for XMR at or below current market price if:
* the offer maker is a preferred trading peer,
* and the offer's BTC amount is between 0.50 and 1.00 BTC,
* and the current transaction mining fee rate is below 15 sats / byte.
* </pre>
* <p>
* <pre>
* Usage: TakeBestPricedOfferToSellBtc --password=api-password --port=api-port \
* [--conf=take-best-priced-offer-to-sell-btc.conf] \
* [--dryrun=true|false]
* [--simulate-regtest-payment=true|false]
* </pre>
*/
@Slf4j
@Getter
public class TakeBestPricedOfferToSellBtc extends AbstractBot {
// Config file: resources/TakeBestPricedOfferToSellBtc.properties.
private final Properties configFile;
// Taker bot's payment account (if the configured paymentAccountId is valid).
private final PaymentAccount paymentAccount;
// Taker bot's payment account trading currency code (if the configured paymentAccountId is valid).
private final String currencyCode;
// Taker bot's max market price margin. A takeable offer's price margin (%) must be <= maxMarketPriceMargin (%).
private final BigDecimal maxMarketPriceMargin;
// Taker bot's min BTC amount to buy (or sell in case of XMR). A takeable offer's amount must be >= minAmount BTC.
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.
private final BigDecimal maxAmount;
// Taker bot's max acceptable transaction fee rate.
private final long maxTxFeeRate;
// Taker bot's trading fee currency code (BSQ or BTC).
private final String bisqTradeFeeCurrency;
// Maximum # of offers to take during one bot session (shut down bot after N swaps).
private final int maxTakeOffers;
// Offer polling frequency must be > 1000 ms between each getoffers request.
private final long pollingInterval;
// The # of BSQ swap offers taken during the bot session (since startup).
private int numOffersTaken = 0;
public TakeBestPricedOfferToSellBtc(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.currencyCode = paymentAccount.getSelectedTradeCurrency().getCode();
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();
validatePollingInterval(pollingInterval);
validateTradeFeeCurrencyCode(bisqTradeFeeCurrency);
validatePaymentAccount(paymentAccount);
printBotConfiguration();
while (!isShutdown) {
if (!isBisqNetworkTxFeeRateLowEnough.test(maxTxFeeRate)) {
runCountdown(log, pollingInterval);
continue;
}
// Taker bot's getOffers(direction) request param. For fiat offers, is SELL (BTC), for XMR offers, is BUY (BTC).
String offerDirection = isXmr.test(currencyCode) ? BUY.name() : SELL.name();
// Get all available and takeable offers, sorted by price ascending.
// The list contains both fixed-price and market price margin based offers.
var offers = getOffers(offerDirection, currencyCode).stream()
.filter(o -> !isAlreadyTaken.test(o))
.toList();
if (offers.isEmpty()) {
log.info("No takeable offers found.");
runCountdown(log, pollingInterval);
continue;
}
// Define criteria for taking an offer, based on conf file.
TakeCriteria takeCriteria = new TakeCriteria();
takeCriteria.printCriteriaSummary();
takeCriteria.printOffersAgainstCriteria(offers);
// Find takeable offer based on criteria.
Optional<OfferInfo> selectedOffer = takeCriteria.findTakeableOffer(offers);
// Try to take the offer, if found, or say 'no offer found' before going to sleep.
selectedOffer.ifPresentOrElse(offer -> takeOffer(takeCriteria, offer),
() -> {
var cheapestOffer = offers.get(0);
log.info("No acceptable offer found. Closest possible candidate did not pass filters:");
takeCriteria.printOfferAgainstCriteria(cheapestOffer);
});
printDryRunProgress();
runCountdown(log, pollingInterval);
pingDaemon(startTime);
}
}
/**
* Attempt to take the available offer according to configured criteria. If successful, will block until a new
* trade is fully initialized with a trade contract. Otherwise, handles a non-fatal error and allows the bot to
* stay alive, or shuts down the bot upon fatal error.
*/
private void takeOffer(TakeCriteria takeCriteria, OfferInfo offer) {
log.info("Will attempt to take offer '{}'.", offer.getId());
takeCriteria.printOfferAgainstCriteria(offer);
if (isDryRun) {
addToOffersTaken(offer);
numOffersTaken++;
maybeShutdownAfterSuccessfulTradeCreation();
} else {
// An encrypted wallet must be unlocked before calling takeoffer and gettrade.
// Unlock the wallet for 5 minutes. If the wallet is already unlocked,
// this command will override the timeout of the previous unlock command.
try {
unlockWallet(walletPassword, 600);
printBTCBalances("BTC Balances Before Take Offer Attempt");
// Blocks until new trade is prepared, or times out.
takeV1ProtocolOffer(offer, paymentAccount, bisqTradeFeeCurrency, pollingInterval);
printBTCBalances("BTC Balances After Take Offer Attempt");
if (canSimulatePaymentSteps) {
var newTrade = getTrade(offer.getId());
RegtestTradePaymentSimulator tradePaymentSimulator = new RegtestTradePaymentSimulator(args,
newTrade.getTradeId(),
paymentAccount);
tradePaymentSimulator.run();
log.info("Trade payment simulation is complete. Closing bot channels and shutting down.");
printBTCBalances("BTC Balances After Simulated Trade Completion");
}
numOffersTaken++;
maybeShutdownAfterSuccessfulTradeCreation();
} catch (NonFatalException nonFatalException) {
handleNonFatalException(nonFatalException);
} catch (StatusRuntimeException fatalException) {
handleFatalException(fatalException);
}
}
}
/**
* Log the non-fatal exception, and stall the bot if the NonFatalException has a stallTime value > 0.
*/
private void handleNonFatalException(NonFatalException nonFatalException) {
log.warn(nonFatalException.getMessage());
if (nonFatalException.hasStallTime()) {
long stallTime = nonFatalException.getStallTime();
log.warn("A minute must pass between the previous and the next takeoffer attempt."
+ " Stalling for {} seconds before the next takeoffer attempt.",
toSeconds.apply(stallTime + pollingInterval));
runCountdown(log, stallTime);
} else {
runCountdown(log, pollingInterval);
}
}
/**
* Log the fatal exception, and shut down daemon and bot.
*/
private void handleFatalException(StatusRuntimeException fatalException) {
log.error("", fatalException);
shutdownAfterFailedTradePreparation();
}
/**
* Lock the wallet, stop the API daemon, and terminate the bot.
*/
private void maybeShutdownAfterSuccessfulTradeCreation() {
if (!isDryRun) {
try {
lockWallet();
} catch (NonFatalException ex) {
log.warn(ex.getMessage());
}
}
if (numOffersTaken >= maxTakeOffers) {
isShutdown = true;
if (canSimulatePaymentSteps) {
log.info("Shutting down bot after successful trade completion. API daemon will not be shut down.");
sleep(2_000);
} else {
log.info("Shutting down API daemon and bot after taking {} offers."
+ " Complete the trade(s) with the desktop UI.",
numOffersTaken);
sleep(2_000);
log.info("Sending stop request to daemon.");
stopDaemon();
}
exit(0);
} else {
log.info("You have taken {} offers during this bot session.", numOffersTaken);
}
}
/**
* Lock the wallet, stop the API daemon, and terminate the bot with a non-zero status (error).
*/
private void shutdownAfterFailedTradePreparation() {
shutdownAfterFatalError("Shutting down API daemon and bot after failing to find new trade.");
}
/**
* Return true is fixed-price offer's price <= the bot's max market price margin. Allows bot to take a
* fixed-priced offer if the price is <= {@link #maxMarketPriceMargin} (%) of the current market price.
*/
protected final BiPredicate<OfferInfo, BigDecimal> isFixedPriceLEMaxMarketPriceMargin =
(offer, currentMarketPrice) -> BotUtils.isFixedPriceLEMaxMarketPriceMargin(
offer,
currentMarketPrice,
getMaxMarketPriceMargin());
/**
* Return true if offer.amt >= bot.minAmt AND offer.amt <= bot.maxAmt (within the boundaries).
* TODO API's takeoffer needs to support taking offer's minAmount.
*/
protected final Predicate<OfferInfo> isWithinBTCAmountBounds = (offer) ->
BotUtils.isWithinBTCAmountBounds(offer, getMinAmount(), getMaxAmount());
private void printBotConfiguration() {
var configsByLabel = new LinkedHashMap<String, Object>();
configsByLabel.put("Bot OS:", getOSName() + " " + getOSVersion());
var network = getNetwork();
configsByLabel.put("BTC Network:", network);
configsByLabel.put("My Payment Account:", "");
configsByLabel.put("\tPayment Account Id:", paymentAccount.getId());
configsByLabel.put("\tAccount Name:", paymentAccount.getAccountName());
configsByLabel.put("\tCurrency Code:", currencyCode);
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) {
@SuppressWarnings("unused")
String prompt = "An encrypted wallet must be unlocked before any offer can be taken.\n"
+ " Please enter your wallet password:";
String walletPassword = "be careful"; // readWalletPassword(prompt);
log.info("Your wallet password is {}", walletPassword.isBlank() ? "blank" : walletPassword);
TakeBestPricedOfferToSellBtc bot = new TakeBestPricedOfferToSellBtc(appendWalletPasswordOpt(args, walletPassword));
bot.run();
}
/**
* Calculates additional takeoffer criteria based on conf file values,
* performs candidate offer filtering, and provides useful log statements.
*/
private class TakeCriteria {
private final BigDecimal currentMarketPrice;
@Getter
private final BigDecimal targetPrice;
private final Supplier<String> marketDescription = () -> {
if (isXmr.test(currencyCode))
return "Sell XMR (Buy BTC)";
else
return "Sell BTC";
};
public TakeCriteria() {
this.currentMarketPrice = getCurrentMarketPrice(currencyCode);
this.targetPrice = calcTargetPrice(maxMarketPriceMargin, currentMarketPrice, currencyCode);
}
/**
* 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.
*
* @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(isWithinBTCAmountBounds)
.findFirst();
else
return offers.stream()
.filter(o -> usesSamePaymentMethod.test(o, getPaymentAccount()))
.filter(o -> isMarginLEMaxMarketPriceMargin.test(o, maxMarketPriceMargin)
|| isFixedPriceLEMaxMarketPriceMargin.test(o, currentMarketPrice))
.filter(isWithinBTCAmountBounds)
.findFirst();
}
void printCriteriaSummary() {
log.info("Looking for offers to {}, priced at or less than {}% {} the current market price {} {}.",
marketDescription.get(),
maxMarketPriceMargin.abs(), // Hide the sign, text explains target price % "above or below".
aboveOrBelowMarketPrice.apply(maxMarketPriceMargin),
currentMarketPrice,
isXmr.test(currencyCode) ? "BTC" : currencyCode);
}
void printOffersAgainstCriteria(List<OfferInfo> offers) {
log.info("Currently available {} offers -- want to take {} offer with price <= {} {}.",
marketDescription.get(),
currencyCode,
targetPrice,
isXmr.test(currencyCode) ? "BTC" : currencyCode);
printOffersSummary(offers);
}
void printOfferAgainstCriteria(OfferInfo offer) {
printOfferSummary(offer);
var filterResultsByLabel = new LinkedHashMap<String, Object>();
filterResultsByLabel.put("Current Market Price:", currentMarketPrice + " " + currencyCode);
filterResultsByLabel.put("Target Price (Max):", targetPrice + " " + currencyCode);
filterResultsByLabel.put("Offer Price:", offer.getPrice() + " " + currencyCode);
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");
var marginPriceLabel = format("Is offer's price margin (%s%%) <= bot's max market price margin (%s%%)?",
offer.getMarketPriceMarginPct(),
maxMarketPriceMargin);
filterResultsByLabel.put(marginPriceLabel,
offer.getUseMarketBasedPrice()
? isMarginLEMaxMarketPriceMargin.test(offer, maxMarketPriceMargin)
: "N/A");
var fixedPriceLabel = format("Is offer's fixed-price (%s) <= bot's target price (%s)?",
offer.getUseMarketBasedPrice() ? "N/A" : offer.getPrice() + " " + currencyCode,
offer.getUseMarketBasedPrice() ? "N/A" : targetPrice + " " + currencyCode);
filterResultsByLabel.put(fixedPriceLabel,
offer.getUseMarketBasedPrice()
? "N/A"
: isFixedPriceLEMaxMarketPriceMargin.test(offer, currentMarketPrice));
String btcAmountBounds = format("%s BTC - %s BTC", minAmount, maxAmount);
filterResultsByLabel.put("Is offer's BTC amount within bot amount bounds (" + btcAmountBounds + ")?",
isWithinBTCAmountBounds.test(offer));
var title = format("%s offer %s filter results:",
offer.getUseMarketBasedPrice() ? "Margin based" : "Fixed price",
offer.getId());
log.info(toTable.apply(title, filterResultsByLabel));
}
}
}