From c708f26c3214a32d3e019034f965fdb4946b0287 Mon Sep 17 00:00:00 2001 From: gazbert Date: Fri, 15 Nov 2024 19:40:47 +0000 Subject: [PATCH] chore: Tmp addition of old Coinbase adapter (#165) * Added Coinbase Pro adapter - will be used as boilerplate for building new Coinbase Advanced Trade adapter. --- .../bxbot/exchanges/CoinbaseProIT.java | 150 ++ .../exchanges/CoinbaseProExchangeAdapter.java | 960 +++++++++++++ .../exchange-data/coinbasepro/accounts.json | 26 + .../test/exchange-data/coinbasepro/book.json | 507 +++++++ .../exchange-data/coinbasepro/cancel.json | 1 + .../coinbasepro/new_buy_order.json | 16 + .../coinbasepro/new_sell_order.json | 16 + .../exchange-data/coinbasepro/orders.json | 66 + .../test/exchange-data/coinbasepro/stats.json | 8 + .../exchange-data/coinbasepro/ticker.json | 9 + .../TestCoinbaseProExchangeAdapter.java | 1224 +++++++++++++++++ config/samples/bitfinex/engine.yaml | 4 +- config/samples/bitstamp/engine.yaml | 4 +- config/samples/coinbase/email-alerts.yaml | 24 + config/samples/coinbase/engine.yaml | 34 + config/samples/coinbase/exchange.yaml | 65 + config/samples/coinbase/markets.yaml | 33 + config/samples/coinbase/strategies.yaml | 45 + config/samples/gemini/engine.yaml | 4 +- config/samples/kraken/engine.yaml | 2 +- etc/checkstyle-suppressions.xml | 3 + etc/spotbugs-exclude-filter.xml | 14 + 22 files changed, 3208 insertions(+), 7 deletions(-) create mode 100644 bxbot-exchanges/src/integration-test/java/com/gazbert/bxbot/exchanges/CoinbaseProIT.java create mode 100644 bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/CoinbaseProExchangeAdapter.java create mode 100644 bxbot-exchanges/src/test/exchange-data/coinbasepro/accounts.json create mode 100644 bxbot-exchanges/src/test/exchange-data/coinbasepro/book.json create mode 100644 bxbot-exchanges/src/test/exchange-data/coinbasepro/cancel.json create mode 100644 bxbot-exchanges/src/test/exchange-data/coinbasepro/new_buy_order.json create mode 100644 bxbot-exchanges/src/test/exchange-data/coinbasepro/new_sell_order.json create mode 100644 bxbot-exchanges/src/test/exchange-data/coinbasepro/orders.json create mode 100644 bxbot-exchanges/src/test/exchange-data/coinbasepro/stats.json create mode 100644 bxbot-exchanges/src/test/exchange-data/coinbasepro/ticker.json create mode 100644 bxbot-exchanges/src/test/java/com/gazbert/bxbot/exchanges/TestCoinbaseProExchangeAdapter.java create mode 100644 config/samples/coinbase/email-alerts.yaml create mode 100644 config/samples/coinbase/engine.yaml create mode 100644 config/samples/coinbase/exchange.yaml create mode 100644 config/samples/coinbase/markets.yaml create mode 100644 config/samples/coinbase/strategies.yaml diff --git a/bxbot-exchanges/src/integration-test/java/com/gazbert/bxbot/exchanges/CoinbaseProIT.java b/bxbot-exchanges/src/integration-test/java/com/gazbert/bxbot/exchanges/CoinbaseProIT.java new file mode 100644 index 000000000..519096cae --- /dev/null +++ b/bxbot-exchanges/src/integration-test/java/com/gazbert/bxbot/exchanges/CoinbaseProIT.java @@ -0,0 +1,150 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2021 Gareth Jon Lynch + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.gazbert.bxbot.exchanges; + +import static org.easymock.EasyMock.createMock; +import static org.easymock.EasyMock.expect; +import static org.easymock.EasyMock.replay; +import static org.easymock.EasyMock.verify; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import com.gazbert.bxbot.exchange.api.AuthenticationConfig; +import com.gazbert.bxbot.exchange.api.ExchangeAdapter; +import com.gazbert.bxbot.exchange.api.ExchangeConfig; +import com.gazbert.bxbot.exchange.api.NetworkConfig; +import com.gazbert.bxbot.exchange.api.OtherConfig; +import com.gazbert.bxbot.trading.api.BalanceInfo; +import com.gazbert.bxbot.trading.api.MarketOrderBook; +import com.gazbert.bxbot.trading.api.Ticker; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; + +/** + * Basic integration testing with Coinbase Pro exchange. + * + * @author gazbert + */ +public class CoinbaseProIT { + + private static final String MARKET_ID = "BTC-GBP"; + private static final BigDecimal BUY_ORDER_PRICE = new BigDecimal("450.176"); + private static final BigDecimal BUY_ORDER_QUANTITY = new BigDecimal("0.01"); + + private static final String PASSPHRASE = "lePassPhrase"; + private static final String KEY = "key123"; + private static final String SECRET = "notGonnaTellYa"; + private static final List nonFatalNetworkErrorCodes = Arrays.asList(502, 503, 504); + private static final List nonFatalNetworkErrorMessages = + Arrays.asList( + "Connection refused", + "Connection reset", + "Remote host closed connection during handshake"); + + private ExchangeConfig exchangeConfig; + private AuthenticationConfig authenticationConfig; + private NetworkConfig networkConfig; + private OtherConfig otherConfig; + + /** Create some exchange config - the TradingEngine would normally do this. */ + @Before + public void setupForEachTest() { + authenticationConfig = createMock(AuthenticationConfig.class); + expect(authenticationConfig.getItem("passphrase")).andReturn(PASSPHRASE); + expect(authenticationConfig.getItem("key")).andReturn(KEY); + expect(authenticationConfig.getItem("secret")).andReturn(SECRET); + + networkConfig = createMock(NetworkConfig.class); + expect(networkConfig.getConnectionTimeout()).andReturn(30); + expect(networkConfig.getNonFatalErrorCodes()).andReturn(nonFatalNetworkErrorCodes); + expect(networkConfig.getNonFatalErrorMessages()).andReturn(nonFatalNetworkErrorMessages); + + otherConfig = createMock(OtherConfig.class); + expect(otherConfig.getItem("buy-fee")).andReturn("0.25"); + expect(otherConfig.getItem("sell-fee")).andReturn("0.25"); + expect(otherConfig.getItem("time-server-bias")).andReturn("1"); + + exchangeConfig = createMock(ExchangeConfig.class); + expect(exchangeConfig.getAuthenticationConfig()).andReturn(authenticationConfig); + expect(exchangeConfig.getNetworkConfig()).andReturn(networkConfig); + expect(exchangeConfig.getOtherConfig()).andReturn(otherConfig); + } + + @Ignore("TODO: Re-enable for Coinbase Advanced Trade exchange.") + @Test + public void testPublicApiCalls() throws Exception { + replay(authenticationConfig, networkConfig, otherConfig, exchangeConfig); + + final ExchangeAdapter exchangeAdapter = new CoinbaseProExchangeAdapter(); + exchangeAdapter.init(exchangeConfig); + + assertNotNull(exchangeAdapter.getLatestMarketPrice(MARKET_ID)); + + final MarketOrderBook orderBook = exchangeAdapter.getMarketOrders(MARKET_ID); + assertFalse(orderBook.getBuyOrders().isEmpty()); + assertFalse(orderBook.getSellOrders().isEmpty()); + + final Ticker ticker = exchangeAdapter.getTicker(MARKET_ID); + assertNotNull(ticker.getLast()); + assertNotNull(ticker.getAsk()); + assertNotNull(ticker.getBid()); + assertNotNull(ticker.getHigh()); + assertNotNull(ticker.getLow()); + assertNotNull(ticker.getOpen()); + assertNotNull(ticker.getVolume()); + assertNull(ticker.getVwap()); // not provided by Coinbase Pro + assertNotNull(ticker.getTimestamp()); + + verify(authenticationConfig, networkConfig, otherConfig, exchangeConfig); + } + + /* + * You'll need to change the PASSPHRASE, KEY, SECRET, constants to real-world values. + */ + @Ignore("Disabled. Integration testing authenticated API calls requires your secret credentials!") + @Test + public void testAuthenticatedApiCalls() throws Exception { + replay(authenticationConfig, networkConfig, otherConfig, exchangeConfig); + + final ExchangeAdapter exchangeAdapter = new CoinbaseProExchangeAdapter(); + exchangeAdapter.init(exchangeConfig); + + final BalanceInfo balanceInfo = exchangeAdapter.getBalanceInfo(); + assertNotNull(balanceInfo.getBalancesAvailable().get("BTC")); + + // Careful here: make sure the SELL_ORDER_PRICE is sensible! + // final String orderId = exchangeAdapter.createOrder(MARKET_ID, OrderType.BUY, + // BUY_ORDER_QUANTITY, BUY_ORDER_PRICE); + // final List openOrders = exchangeAdapter.getYourOpenOrders(MARKET_ID); + // assertTrue(openOrders.stream().anyMatch(o -> o.getId().equals(orderId))); + // assertTrue(exchangeAdapter.cancelOrder(orderId, MARKET_ID)); + + verify(authenticationConfig, networkConfig, otherConfig, exchangeConfig); + } +} diff --git a/bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/CoinbaseProExchangeAdapter.java b/bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/CoinbaseProExchangeAdapter.java new file mode 100644 index 000000000..e8f281894 --- /dev/null +++ b/bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/CoinbaseProExchangeAdapter.java @@ -0,0 +1,960 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 Gareth Jon Lynch + * Copyright (c) 2019 David Huertas + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.gazbert.bxbot.exchanges; + +import com.gazbert.bxbot.exchange.api.AuthenticationConfig; +import com.gazbert.bxbot.exchange.api.ExchangeAdapter; +import com.gazbert.bxbot.exchange.api.ExchangeConfig; +import com.gazbert.bxbot.exchange.api.OtherConfig; +import com.gazbert.bxbot.exchanges.trading.api.impl.BalanceInfoImpl; +import com.gazbert.bxbot.exchanges.trading.api.impl.MarketOrderBookImpl; +import com.gazbert.bxbot.exchanges.trading.api.impl.MarketOrderImpl; +import com.gazbert.bxbot.exchanges.trading.api.impl.OpenOrderImpl; +import com.gazbert.bxbot.exchanges.trading.api.impl.TickerImpl; +import com.gazbert.bxbot.trading.api.BalanceInfo; +import com.gazbert.bxbot.trading.api.ExchangeNetworkException; +import com.gazbert.bxbot.trading.api.MarketOrder; +import com.gazbert.bxbot.trading.api.MarketOrderBook; +import com.gazbert.bxbot.trading.api.OpenOrder; +import com.gazbert.bxbot.trading.api.OrderType; +import com.gazbert.bxbot.trading.api.Ticker; +import com.gazbert.bxbot.trading.api.TradingApi; +import com.gazbert.bxbot.trading.api.TradingApiException; +import com.google.common.base.MoreObjects; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.annotations.SerializedName; +import jakarta.xml.bind.DatatypeConverter; +import java.io.Serial; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.text.DecimalFormat; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import lombok.extern.log4j.Log4j2; + +/** + * Exchange Adapter for integrating with the CoinbasePro exchange. The CoinbasePro API is documented + * here. + * + *

DISCLAIMER: This Exchange Adapter is provided as-is; it might have bugs in it and you + * could lose money. Despite running live on COINBASE PRO, it has only been unit tested up until the + * point of calling the {@link #sendPublicRequestToExchange(String, Map)} and {@link + * #sendAuthenticatedRequestToExchange(String, String, Map)} methods. Use it at our own risk! + * + * + *

This adapter only supports the CoinbasePro REST + * API. The design of the API and documentation is excellent. + * + *

The adapter currently only supports Limit Orders. It was originally + * developed and tested for BTC-GBP market, but it should work for BTC-USD or BTC-EUR. + * + *

Exchange fees are loaded from the exchange.yaml file on startup; they are not fetched from the + * exchange at runtime as the CoinbasePro REST API does not support this. The fees are used across + * all markets. Make sure you keep an eye on the exchange fees and update the config accordingly. + * + *

NOTE: CoinbasePro requires all price values to be limited to 2 decimal places when creating + * orders. This adapter truncates any prices with more than 2 decimal places and rounds using {@link + * java.math.RoundingMode#HALF_EVEN}, E.g. 250.176 would be sent to the exchange as 250.18. + * + *

The Exchange Adapter is not thread safe. It expects to be called using a single + * thread in order to preserve trade execution order. The {@link URLConnection} achieves this by + * blocking/waiting on the input stream (response) for each API call. + * + *

The {@link TradingApi} calls will throw a {@link ExchangeNetworkException} if a network error + * occurs trying to connect to the exchange. A {@link TradingApiException} is thrown for + * all other failures. + * + * @author davidhuertas + * @since 1.0 + */ +@Log4j2 +public final class CoinbaseProExchangeAdapter extends AbstractExchangeAdapter + implements ExchangeAdapter { + private static final String PUBLIC_API_BASE_URL = "https://api.pro.coinbase.com/"; + private static final String AUTHENTICATED_API_URL = PUBLIC_API_BASE_URL; + + private static final String UNEXPECTED_ERROR_MSG = + "Unexpected error has occurred in COINBASE PRO Exchange Adapter. "; + private static final String UNEXPECTED_IO_ERROR_MSG = + "Failed to connect to Exchange due to unexpected IO error."; + + private static final String PRODUCTS = "products/"; + private static final String PRICE = "price"; + + private static final String PASSPHRASE_PROPERTY_NAME = "passphrase"; + private static final String KEY_PROPERTY_NAME = "key"; + private static final String SECRET_PROPERTY_NAME = "secret"; + + private static final String BUY_FEE_PROPERTY_NAME = "buy-fee"; + private static final String SELL_FEE_PROPERTY_NAME = "sell-fee"; + private static final String SERVER_TIME_BIAS_PROPERTY_NAME = "time-server-bias"; + + private BigDecimal buyFeePercentage; + private BigDecimal sellFeePercentage; + private Long timeServerBias; + + private String passphrase = ""; + private String key = ""; + private String secret = ""; + + private Mac mac; + private boolean initializedMacAuthentication = false; + + private Gson gson; + + @Override + public void init(ExchangeConfig config) { + log.info("About to initialise COINBASE PRO ExchangeConfig: " + config); + setAuthenticationConfig(config); + setNetworkConfig(config); + setOtherConfig(config); + + initSecureMessageLayer(); + initGson(); + } + + // -------------------------------------------------------------------------- + // COINBASE PRO API Calls adapted to the Trading API. + // See https://docs.pro.coinbase.com/#api + // -------------------------------------------------------------------------- + + @Override + public String createOrder( + String marketId, OrderType orderType, BigDecimal quantity, BigDecimal price) + throws TradingApiException, ExchangeNetworkException { + try { + /* + * Build Limit Order: https://docs.pro.coinbase.com/#place-a-new-order + * + * stp param optional - (Self-trade prevention flag) defaults to 'dc' Decrease & + * Cancel + * post_only param optional - defaults to 'false' + * time_in_force param optional - defaults to 'GTC' Good til Cancel + * client_oid param is optional - thia adapter does not use it. + */ + final Map params = createRequestParamMap(); + + if (orderType == OrderType.BUY) { + params.put("side", "buy"); + } else if (orderType == OrderType.SELL) { + params.put("side", "sell"); + } else { + final String errorMsg = + "Invalid order type: " + + orderType + + " - Can only be " + + OrderType.BUY.getStringValue() + + " or " + + OrderType.SELL.getStringValue(); + log.error(errorMsg); + throw new IllegalArgumentException(errorMsg); + } + + params.put("product_id", marketId); + + // note we need to limit price to 2 decimal places else exchange will barf + params.put(PRICE, new DecimalFormat("#.##", getDecimalFormatSymbols()).format(price)); + + // note we need to limit size to 8 decimal places else exchange will barf + params.put( + "size", new DecimalFormat("#.########", getDecimalFormatSymbols()).format(quantity)); + + final ExchangeHttpResponse response = + sendAuthenticatedRequestToExchange("POST", "orders", params); + log.debug("Create Order response: " + response); + + if (response.getStatusCode() == HttpURLConnection.HTTP_OK) { + final CoinbaseProOrder createOrderResponse = + gson.fromJson(response.getPayload(), CoinbaseProOrder.class); + if (createOrderResponse != null + && (createOrderResponse.id != null && !createOrderResponse.id.isEmpty())) { + return createOrderResponse.id; + } else { + final String errorMsg = "Failed to place order on exchange. Error response: " + response; + log.error(errorMsg); + throw new TradingApiException(errorMsg); + } + } else { + final String errorMsg = "Failed to create order on exchange. Details: " + response; + log.error(errorMsg); + throw new TradingApiException(errorMsg); + } + + } catch (ExchangeNetworkException | TradingApiException e) { + throw e; + + } catch (Exception e) { + log.error(UNEXPECTED_ERROR_MSG, e); + throw new TradingApiException(UNEXPECTED_ERROR_MSG, e); + } + } + + /* + * marketId is not needed for cancelling orders on this exchange. + */ + @Override + public boolean cancelOrder(String orderId, String marketIdNotNeeded) + throws TradingApiException, ExchangeNetworkException { + try { + final ExchangeHttpResponse response = + sendAuthenticatedRequestToExchange("DELETE", "orders/" + orderId, null); + + log.debug("Cancel Order response: " + response); + + if (response.getStatusCode() == HttpURLConnection.HTTP_OK) { + // 1 Nov 2017 - COINBASE PRO API no longer returns cancelled orderId in array payload; + // it returns [null]... + return true; + } else { + final String errorMsg = "Failed to cancel order on exchange. Details: " + response; + log.error(errorMsg); + return false; + } + + } catch (ExchangeNetworkException | TradingApiException e) { + throw e; + + } catch (Exception e) { + log.error(UNEXPECTED_ERROR_MSG, e); + throw new TradingApiException(UNEXPECTED_ERROR_MSG, e); + } + } + + @Override + public List getYourOpenOrders(String marketId) + throws TradingApiException, ExchangeNetworkException { + try { + // we use default request no-param call - only open or un-settled orders are returned. + // As soon as an order is no longer open and settled, it will no longer appear in the default + // request. + final ExchangeHttpResponse response = + sendAuthenticatedRequestToExchange("GET", "orders", null); + + log.debug("Open Orders response: " + response); + + if (response.getStatusCode() == HttpURLConnection.HTTP_OK) { + final CoinbaseProOrder[] coinbaseProOpenOrders = + gson.fromJson(response.getPayload(), CoinbaseProOrder[].class); + final List ordersToReturn = new ArrayList<>(); + for (final CoinbaseProOrder openOrder : coinbaseProOpenOrders) { + + if (!marketId.equalsIgnoreCase(openOrder.productId)) { + continue; + } + + OrderType orderType; + switch (openOrder.side) { + case "buy": + orderType = OrderType.BUY; + break; + case "sell": + orderType = OrderType.SELL; + break; + default: + throw new TradingApiException( + "Unrecognised order type received in getYourOpenOrders(). Value: " + + openOrder.side); + } + + final OpenOrder order = + new OpenOrderImpl( + openOrder.id, + Date.from(Instant.parse(openOrder.createdAt)), + marketId, + orderType, + openOrder.price, + openOrder.size.subtract( + openOrder.filledSize), // quantity remaining - not provided by COINBASE PRO + openOrder.size, // orig quantity + openOrder.price.multiply(openOrder.size) // total - not provided by COINBASE PRO + ); + + ordersToReturn.add(order); + } + return ordersToReturn; + } else { + final String errorMsg = + "Failed to get your open orders from exchange. Details: " + response; + log.error(errorMsg); + throw new TradingApiException(errorMsg); + } + + } catch (ExchangeNetworkException | TradingApiException e) { + throw e; + + } catch (Exception e) { + log.error(UNEXPECTED_ERROR_MSG, e); + throw new TradingApiException(UNEXPECTED_ERROR_MSG, e); + } + } + + @Override + public MarketOrderBook getMarketOrders(String marketId) + throws TradingApiException, ExchangeNetworkException { + try { + final Map params = createRequestParamMap(); + params.put("level", "2"); // "2" = Top 50 bids and asks (aggregated) + + final ExchangeHttpResponse response = + sendPublicRequestToExchange(PRODUCTS + marketId + "/book", params); + + log.debug("Market Orders response: " + response); + + if (response.getStatusCode() == HttpURLConnection.HTTP_OK) { + final CoinbaseProBookWrapper orderBook = + gson.fromJson(response.getPayload(), CoinbaseProBookWrapper.class); + + final List buyOrders = new ArrayList<>(); + for (CoinbaseProMarketOrder coinbaseProBuyOrder : orderBook.bids) { + final MarketOrder buyOrder = + new MarketOrderImpl( + OrderType.BUY, + coinbaseProBuyOrder.get(0), + coinbaseProBuyOrder.get(1), + coinbaseProBuyOrder.get(0).multiply(coinbaseProBuyOrder.get(1))); + buyOrders.add(buyOrder); + } + + final List sellOrders = new ArrayList<>(); + for (CoinbaseProMarketOrder coinbaseProSellOrder : orderBook.asks) { + final MarketOrder sellOrder = + new MarketOrderImpl( + OrderType.SELL, + coinbaseProSellOrder.get(0), + coinbaseProSellOrder.get(1), + coinbaseProSellOrder.get(0).multiply(coinbaseProSellOrder.get(1))); + sellOrders.add(sellOrder); + } + return new MarketOrderBookImpl(marketId, sellOrders, buyOrders); + + } else { + final String errorMsg = + "Failed to get market order book from exchange. Details: " + response; + log.error(errorMsg); + throw new TradingApiException(errorMsg); + } + + } catch (ExchangeNetworkException | TradingApiException e) { + throw e; + + } catch (Exception e) { + log.error(UNEXPECTED_ERROR_MSG, e); + throw new TradingApiException(UNEXPECTED_ERROR_MSG, e); + } + } + + @Override + public BalanceInfo getBalanceInfo() throws TradingApiException, ExchangeNetworkException { + try { + final ExchangeHttpResponse response = + sendAuthenticatedRequestToExchange("GET", "accounts", null); + + log.debug("Balance Info response: " + response); + + if (response.getStatusCode() == HttpURLConnection.HTTP_OK) { + final CoinbaseProAccount[] coinbaseProAccounts = + gson.fromJson(response.getPayload(), CoinbaseProAccount[].class); + + final HashMap balancesAvailable = new HashMap<>(); + final HashMap balancesOnHold = new HashMap<>(); + + for (final CoinbaseProAccount coinbaseProAccount : coinbaseProAccounts) { + balancesAvailable.put(coinbaseProAccount.currency, coinbaseProAccount.available); + balancesOnHold.put(coinbaseProAccount.currency, coinbaseProAccount.hold); + } + return new BalanceInfoImpl(balancesAvailable, balancesOnHold); + } else { + final String errorMsg = + "Failed to get your wallet balance info from exchange. Details: " + response; + log.error(errorMsg); + throw new TradingApiException(errorMsg); + } + + } catch (ExchangeNetworkException | TradingApiException e) { + throw e; + } catch (Exception e) { + log.error(UNEXPECTED_ERROR_MSG, e); + throw new TradingApiException(UNEXPECTED_ERROR_MSG, e); + } + } + + @Override + public BigDecimal getLatestMarketPrice(String marketId) + throws ExchangeNetworkException, TradingApiException { + try { + final ExchangeHttpResponse response = + sendPublicRequestToExchange(PRODUCTS + marketId + "/ticker", null); + + log.debug("Latest Market Price response: " + response); + + if (response.getStatusCode() == HttpURLConnection.HTTP_OK) { + final CoinbaseProTicker coinbaseProTicker = + gson.fromJson(response.getPayload(), CoinbaseProTicker.class); + return coinbaseProTicker.price; + } else { + final String errorMsg = "Failed to get market ticker from exchange. Details: " + response; + log.error(errorMsg); + throw new TradingApiException(errorMsg); + } + + } catch (ExchangeNetworkException | TradingApiException e) { + throw e; + + } catch (Exception e) { + log.error(UNEXPECTED_ERROR_MSG, e); + throw new TradingApiException(UNEXPECTED_ERROR_MSG, e); + } + } + + /* + * COINBASE PRO does not provide API call for fetching % buy fee; it only provides the fee + * monetary value for a given order via e.g. /orders/ API call. We load the % fee + * statically from exchange.yaml file. + */ + @Override + public BigDecimal getPercentageOfBuyOrderTakenForExchangeFee(String marketId) { + return buyFeePercentage; + } + + /* + * COINBASE PRO does not provide API call for fetching % sell fee; it only provides the fee + * monetary value for a given order via e.g. /orders/ API call. We load the % fee + * statically from exchange.yaml file. + */ + @Override + public BigDecimal getPercentageOfSellOrderTakenForExchangeFee(String marketId) { + return sellFeePercentage; + } + + @Override + public String getImplName() { + return "COINBASE PRO REST API v1"; + } + + @Override + public Ticker getTicker(String marketId) throws ExchangeNetworkException, TradingApiException { + try { + final ExchangeHttpResponse tickerResponse = + sendPublicRequestToExchange(PRODUCTS + marketId + "/ticker", null); + + log.debug("Ticker response: " + tickerResponse); + + if (tickerResponse.getStatusCode() == HttpURLConnection.HTTP_OK) { + final CoinbaseProTicker coinbaseProTicker = + gson.fromJson(tickerResponse.getPayload(), CoinbaseProTicker.class); + + final TickerImpl ticker = + new TickerImpl( + coinbaseProTicker.price, + coinbaseProTicker.bid, + coinbaseProTicker.ask, + null, // low, + null, // high, + null, // open, + coinbaseProTicker.volume, + null, // vwap - not supplied by COINBASE PRO + Date.from(Instant.parse(coinbaseProTicker.time)).getTime()); + + // Now we need to call the stats operation to get the 24hr indicators + final ExchangeHttpResponse statsResponse = + sendPublicRequestToExchange(PRODUCTS + marketId + "/stats", null); + + log.debug("Stats response: " + statsResponse); + + if (statsResponse.getStatusCode() == HttpURLConnection.HTTP_OK) { + final CoinbaseProStats coinbaseProStats = + gson.fromJson(statsResponse.getPayload(), CoinbaseProStats.class); + ticker.setLow(coinbaseProStats.low); + ticker.setHigh(coinbaseProStats.high); + ticker.setOpen(coinbaseProStats.open); + } else { + final String errorMsg = "Failed to get stats from exchange. Details: " + statsResponse; + log.error(errorMsg); + throw new TradingApiException(errorMsg); + } + + return ticker; + + } else { + final String errorMsg = + "Failed to get market ticker from exchange. Details: " + tickerResponse; + log.error(errorMsg); + throw new TradingApiException(errorMsg); + } + + } catch (ExchangeNetworkException | TradingApiException e) { + throw e; + + } catch (Exception e) { + log.error(UNEXPECTED_ERROR_MSG, e); + throw new TradingApiException(UNEXPECTED_ERROR_MSG, e); + } + } + + // -------------------------------------------------------------------------- + // GSON classes for JSON responses. + // See https://docs.pro.coinbase.com/#api + // -------------------------------------------------------------------------- + + /** + * GSON class for COINBASE PRO '/orders' API call response. + * + *

There are other critters in here different to what is spec'd. + */ + private static class CoinbaseProOrder { + + String id; + BigDecimal price; + BigDecimal size; + + @SerializedName("product_id") + String productId; // e.g. "BTC-GBP", "BTC-USD" + + String side; // "buy" or "sell" + String stp; // Self-Trade Prevention flag, e.g. "dc" + String type; // order type, e.g. "limit" + + @SerializedName("time_in_force") + String timeInForce; // e.g. "GTC" (Good Til Cancelled) + + @SerializedName("post_only") + boolean postOnly; // shows in book + provides exchange liquidity, but will not execute + + @SerializedName("created_at") + String createdAt; // e.g. "2014-11-14 06:39:55.189376+00" + + @SerializedName("fill_fees") + BigDecimal fillFees; + + @SerializedName("filled_size") + BigDecimal filledSize; + + String status; // e.g. "open" + boolean settled; + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add(PRICE, price) + .add("size", size) + .add("productId", productId) + .add("side", side) + .add("stp", stp) + .add("type", type) + .add("timeInForce", timeInForce) + .add("postOnly", postOnly) + .add("createdAt", createdAt) + .add("fillFees", fillFees) + .add("filledSize", filledSize) + .add("status", status) + .add("settled", settled) + .toString(); + } + } + + /** GSON class for COINBASE PRO '/products/{marketId}/book' API call response. */ + private static class CoinbaseProBookWrapper { + + long sequence; + List bids; + List asks; + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("sequence", sequence) + .add("bids", bids) + .add("asks", asks) + .toString(); + } + } + + /** + * GSON class for holding Market Orders. First element in array is price, second element is + * amount, third is number of orders. + */ + private static class CoinbaseProMarketOrder extends ArrayList { + + @Serial private static final long serialVersionUID = -4919711220797077759L; + } + + /** GSON class for COINBASE PRO '/products/{marketId}/ticker' API call response. */ + private static class CoinbaseProTicker { + + @SerializedName("trade_id") + long tradeId; + + BigDecimal price; + BigDecimal size; + BigDecimal bid; + BigDecimal ask; + BigDecimal volume; + String time; // e.g. "2015-10-14T19:19:36.604735Z" + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("tradeId", tradeId) + .add(PRICE, price) + .add("size", size) + .add("bid", bid) + .add("ask", ask) + .add("volume", volume) + .add("time", time) + .toString(); + } + } + + /** GSON class for COINBASE PRO '/products/<product-id>/stats' API call response. */ + private static class CoinbaseProStats { + + BigDecimal open; + BigDecimal high; + BigDecimal low; + BigDecimal volume; + BigDecimal last; + + @SerializedName("volume_30day") + String volume30Day; + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("open", open) + .add("high", high) + .add("low", low) + .add("volume", volume) + .add("last", last) + .add("volume30Day", volume30Day) + .toString(); + } + } + + /** GSON class for COINBASE PRO '/accounts' API call response. */ + private static class CoinbaseProAccount { + + String id; + String currency; + BigDecimal balance; // e.g. "0.0000000000000000" + BigDecimal hold; + BigDecimal available; + + @SerializedName("profile_id") // no idea what this is?! + String profileId; + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("currency", currency) + .add("balance", balance) + .add("hold", hold) + .add("available", available) + .add("profileId", profileId) + .toString(); + } + } + + // -------------------------------------------------------------------------- + // Transport layer methods + // -------------------------------------------------------------------------- + + private ExchangeHttpResponse sendPublicRequestToExchange( + String apiMethod, Map params) + throws ExchangeNetworkException, TradingApiException { + if (params == null) { + params = createRequestParamMap(); // no params, so empty query string + } + + // Request headers required by Exchange + final Map requestHeaders = new HashMap<>(); + + try { + + final StringBuilder queryString = new StringBuilder(); + if (params.size() > 0) { + + queryString.append("?"); + + for (final Map.Entry param : params.entrySet()) { + if (queryString.length() > 1) { + queryString.append("&"); + } + queryString.append(param.getKey()); + queryString.append("="); + queryString.append(URLEncoder.encode(param.getValue(), StandardCharsets.UTF_8)); + } + + requestHeaders.put("Content-Type", "application/x-www-form-urlencoded"); + } + + final URL url = new URL(PUBLIC_API_BASE_URL + apiMethod + queryString); + return makeNetworkRequest(url, "GET", null, requestHeaders); + + } catch (MalformedURLException e) { + final String errorMsg = UNEXPECTED_IO_ERROR_MSG; + log.error(errorMsg, e); + throw new TradingApiException(errorMsg, e); + } + } + + /* + * Makes an authenticated API call to the COINBASE PRO exchange. + * + * The COINBASE PRO authentication process is complex, but well documented: + * https://docs.pro.coinbase.com/#creating-a-request + * + * All REST requests must contain the following headers: + * + * CB-ACCESS-KEY The api key as a string. + * CB-ACCESS-SIGN The base64-encoded signature (see Signing a Message). + * CB-ACCESS-TIMESTAMP A timestamp for your request. + * CB-ACCESS-PASSPHRASE The passphrase you specified when creating the API key. + * + * The CB-ACCESS-TIMESTAMP header MUST be number of seconds since Unix Epoch in UTC. + * Decimal values are allowed. + * + * Your timestamp must be within 30 seconds of the api service time or your request will be + * considered expired and rejected. We recommend using the time endpoint to query for the API + * server time if you believe there many be time skew between your server and the API servers. + * + * All request bodies should have content type application/json and be valid JSON. + * + * The CB-ACCESS-SIGN header is generated by creating a sha256 HMAC using the base64-decoded + * secret key on the prehash string: + * + * timestamp + method + requestPath + body (where + represents string concatenation) + * + * and base64-encode the output. + * The timestamp value is the same as the CB-ACCESS-TIMESTAMP header. + * + * The body is the request body string or omitted if there is no request body + * (typically for GET requests). + * + * The method should be UPPER CASE. + * + * Remember to first base64-decode the alphanumeric secret string (resulting in 64 bytes) before + * using it as the key for HMAC. Also, base64-encode the digest output before sending in the + * header. + */ + private ExchangeHttpResponse sendAuthenticatedRequestToExchange( + String httpMethod, String apiMethod, Map params) + throws ExchangeNetworkException, TradingApiException { + + if (!initializedMacAuthentication) { + final String errorMsg = "MAC Message security layer has not been initialized."; + log.error(errorMsg); + throw new IllegalStateException(errorMsg); + } + + try { + if (params == null) { + // create empty map for non-param API calls + params = createRequestParamMap(); + } + + // Build the request + final String invocationUrl; + String requestBody = ""; + + switch (httpMethod) { + case "GET": + log.debug("Building secure GET request..."); + // Build (optional) query param string + final StringBuilder queryParamBuilder = new StringBuilder(); + for (final Map.Entry param : params.entrySet()) { + if (queryParamBuilder.length() > 0) { + queryParamBuilder.append("&"); + } + queryParamBuilder.append(param.getKey()); + queryParamBuilder.append("="); + queryParamBuilder.append(param.getValue()); + } + + final String queryParams = queryParamBuilder.toString(); + log.debug("Query param string: " + queryParams); + + if (params.isEmpty()) { + invocationUrl = AUTHENTICATED_API_URL + apiMethod; + } else { + invocationUrl = AUTHENTICATED_API_URL + apiMethod + "?" + queryParams; + } + break; + + case "POST": + log.debug("Building secure POST request..."); + invocationUrl = AUTHENTICATED_API_URL + apiMethod; + requestBody = gson.toJson(params); + break; + + case "DELETE": + log.debug("Building secure DELETE request..."); + invocationUrl = AUTHENTICATED_API_URL + apiMethod; + break; + + default: + throw new IllegalArgumentException( + "Don't know how to build secure [" + httpMethod + "] request!"); + } + + // Get UNIX EPOCH in secs and add the time-server bias + final long timeServer = Instant.now().getEpochSecond() + timeServerBias; + final String timestamp = Long.toString(timeServer); + log.debug("Server UNIX EPOCH in seconds: " + timestamp); + + // Build the signature string: timestamp + method + requestPath + body + final String signatureBuilder = + timestamp + httpMethod.toUpperCase() + "/" + apiMethod + requestBody; + + // Sign the signature string and Base64 encode it + mac.reset(); + mac.update(signatureBuilder.getBytes(StandardCharsets.UTF_8)); + final String signature = DatatypeConverter.printBase64Binary(mac.doFinal()); + + // Request headers required by Exchange + final Map requestHeaders = createHeaderParamMap(); + requestHeaders.put("Content-Type", "application/json"); + requestHeaders.put("CB-ACCESS-KEY", key); + requestHeaders.put("CB-ACCESS-SIGN", signature); + requestHeaders.put("CB-ACCESS-TIMESTAMP", timestamp); + requestHeaders.put("CB-ACCESS-PASSPHRASE", passphrase); + + final URL url = new URL(invocationUrl); + return makeNetworkRequest(url, httpMethod, requestBody, requestHeaders); + + } catch (MalformedURLException e) { + final String errorMsg = UNEXPECTED_IO_ERROR_MSG; + log.error(errorMsg, e); + throw new TradingApiException(errorMsg, e); + } + } + + /* + * Initialises the secure messaging layer. + * Sets up the MAC to safeguard the data we send to the exchange. + * Used to encrypt the hash of the entire message with the private key to ensure message + * integrity. We fail hard n fast if any of this stuff blows. + */ + private void initSecureMessageLayer() { + try { + // COINBASE PRO secret is in Base64, so we must decode it first. + final byte[] decodedBase64Secret = DatatypeConverter.parseBase64Binary(secret); + + final SecretKeySpec keyspec = new SecretKeySpec(decodedBase64Secret, "HmacSHA256"); + mac = Mac.getInstance("HmacSHA256"); + mac.init(keyspec); + initializedMacAuthentication = true; + } catch (NoSuchAlgorithmException e) { + final String errorMsg = "Failed to setup MAC security. HINT: Is HMAC-SHA256 installed?"; + log.error(errorMsg, e); + throw new IllegalStateException(errorMsg, e); + } catch (InvalidKeyException e) { + final String errorMsg = "Failed to setup MAC security. Secret key seems invalid!"; + log.error(errorMsg, e); + throw new IllegalArgumentException(errorMsg, e); + } + } + + // -------------------------------------------------------------------------- + // Config methods + // -------------------------------------------------------------------------- + + private void setAuthenticationConfig(ExchangeConfig exchangeConfig) { + final AuthenticationConfig authenticationConfig = getAuthenticationConfig(exchangeConfig); + passphrase = getAuthenticationConfigItem(authenticationConfig, PASSPHRASE_PROPERTY_NAME); + key = getAuthenticationConfigItem(authenticationConfig, KEY_PROPERTY_NAME); + secret = getAuthenticationConfigItem(authenticationConfig, SECRET_PROPERTY_NAME); + } + + private void setOtherConfig(ExchangeConfig exchangeConfig) { + final OtherConfig otherConfig = getOtherConfig(exchangeConfig); + + final String buyFeeInConfig = getOtherConfigItem(otherConfig, BUY_FEE_PROPERTY_NAME); + buyFeePercentage = + new BigDecimal(buyFeeInConfig).divide(new BigDecimal("100"), 8, RoundingMode.HALF_UP); + log.info("Buy fee % in BigDecimal format: " + buyFeePercentage); + + final String sellFeeInConfig = getOtherConfigItem(otherConfig, SELL_FEE_PROPERTY_NAME); + sellFeePercentage = + new BigDecimal(sellFeeInConfig).divide(new BigDecimal("100"), 8, RoundingMode.HALF_UP); + log.info("Sell fee % in BigDecimal format: " + sellFeePercentage); + + final String serverTimeBiasInConfig = + getOtherConfigItem(otherConfig, SERVER_TIME_BIAS_PROPERTY_NAME); + timeServerBias = Long.parseLong(serverTimeBiasInConfig); + log.info("Time server bias in long format: " + timeServerBias); + } + + // -------------------------------------------------------------------------- + // Util methods + // -------------------------------------------------------------------------- + + private void initGson() { + final GsonBuilder gsonBuilder = new GsonBuilder(); + gson = gsonBuilder.create(); + } + + /* + * Hack for unit-testing request params passed to transport layer. + */ + private Map createRequestParamMap() { + return new HashMap<>(); + } + + /* + * Hack for unit-testing header params passed to transport layer. + */ + private Map createHeaderParamMap() { + return new HashMap<>(); + } + + /* + * Hack for unit-testing transport layer. + */ + private ExchangeHttpResponse makeNetworkRequest( + URL url, String httpMethod, String postData, Map requestHeaders) + throws TradingApiException, ExchangeNetworkException { + return super.sendNetworkRequest(url, httpMethod, postData, requestHeaders); + } +} diff --git a/bxbot-exchanges/src/test/exchange-data/coinbasepro/accounts.json b/bxbot-exchanges/src/test/exchange-data/coinbasepro/accounts.json new file mode 100644 index 000000000..ed4942c77 --- /dev/null +++ b/bxbot-exchanges/src/test/exchange-data/coinbasepro/accounts.json @@ -0,0 +1,26 @@ +[ + { + "id": "7262ae65-fbc3-4d11-b959-25f1befc7a21", + "currency": "BTC", + "balance": "200.000000000000009", + "hold": "100.0000000000000005", + "available": "100.0000000000000004", + "profile_id": "5624aa4a-85f6-462f-a4d9-bac80ea184c7" + }, + { + "id": "de2b5848-533e-4374-9004-cd8ea985f8cc", + "currency": "GBP", + "balance": "1000.0000000000000003", + "hold": "499.9900000000000002", + "available": "501.0100000000000001", + "profile_id": "5624aa4a-85f6-462f-a4d9-bac80ea184c2" + }, + { + "id": "864881f5-bd18-4970-b254-e7cd2fef6b4d", + "currency": "EUR", + "balance": "0.0000000000000000", + "hold": "0.0000000000000000", + "available": "0.0000000000000000", + "profile_id": "5622aa4a-87f6-462f-c4d9-bac80ef184d0" + } +] \ No newline at end of file diff --git a/bxbot-exchanges/src/test/exchange-data/coinbasepro/book.json b/bxbot-exchanges/src/test/exchange-data/coinbasepro/book.json new file mode 100644 index 000000000..72ff024c1 --- /dev/null +++ b/bxbot-exchanges/src/test/exchange-data/coinbasepro/book.json @@ -0,0 +1,507 @@ +{ + "sequence": 95643108, + "bids": [ + [ + "165.87", + "16.2373", + 10 + ], + [ + "165.86", + "21.645", + 3 + ], + [ + "165.85", + "0.357", + 3 + ], + [ + "165.84", + "15.4652", + 1 + ], + [ + "165.83", + "0.08", + 2 + ], + [ + "165.82", + "0.207", + 4 + ], + [ + "165.8", + "3.46334", + 1 + ], + [ + "165.78", + "43.355346", + 6 + ], + [ + "165.77", + "3.157", + 2 + ], + [ + "165.76", + "32.6", + 4 + ], + [ + "165.75", + "0.02042", + 1 + ], + [ + "165.74", + "0.3264", + 3 + ], + [ + "165.73", + "0.902", + 4 + ], + [ + "165.72", + "0.07", + 1 + ], + [ + "165.71", + "0.38466", + 6 + ], + [ + "165.7", + "23.32458", + 12 + ], + [ + "165.69", + "13.6215", + 1 + ], + [ + "165.68", + "0.08", + 2 + ], + [ + "165.66", + "0.03", + 1 + ], + [ + "165.65", + "0.02", + 2 + ], + [ + "165.63", + "0.051", + 1 + ], + [ + "165.52", + "18.39", + 1 + ], + [ + "165.31", + "0.016", + 1 + ], + [ + "165.3", + "0.0183", + 1 + ], + [ + "165.28", + "0.088", + 1 + ], + [ + "165.26", + "0.053", + 2 + ], + [ + "165.24", + "0.02", + 1 + ], + [ + "165.23", + "3.65", + 3 + ], + [ + "165.22", + "0.01", + 1 + ], + [ + "165.18", + "3.3559", + 2 + ], + [ + "165.16", + "0.031", + 1 + ], + [ + "165.12", + "2.01", + 1 + ], + [ + "165.07", + "3.15", + 1 + ], + [ + "165", + "2.5", + 3 + ], + [ + "164.91", + "3.5", + 1 + ], + [ + "164.62", + "3.64", + 1 + ], + [ + "164.56", + "1.676", + 1 + ], + [ + "164.5", + "1.66", + 1 + ], + [ + "164.49", + "7.82071", + 1 + ], + [ + "164.44", + "3", + 1 + ], + [ + "164.38", + "2.06", + 1 + ], + [ + "164.2", + "1.21", + 1 + ], + [ + "164.13", + "0.02", + 1 + ], + [ + "164.05", + "1.7", + 1 + ], + [ + "164.03", + "3.906", + 1 + ], + [ + "163.97", + "0.6281", + 1 + ], + [ + "163.91", + "2.6", + 1 + ], + [ + "163.84", + "4.7602", + 1 + ], + [ + "163.75", + "2.314", + 1 + ], + [ + "163.73", + "0.3", + 1 + ] + ], + "asks": [ + [ + "165.96", + "24.31", + 1 + ], + [ + "166.05", + "0.01", + 1 + ], + [ + "166.08", + "0.027", + 1 + ], + [ + "166.1", + "3.98718", + 1 + ], + [ + "166.12", + "0.0815", + 2 + ], + [ + "166.13", + "0.1404", + 2 + ], + [ + "166.14", + "0.108", + 3 + ], + [ + "166.16", + "0.784", + 3 + ], + [ + "166.17", + "1.41472", + 3 + ], + [ + "166.18", + "1.3774", + 5 + ], + [ + "166.19", + "0.3284", + 6 + ], + [ + "166.2", + "0.02", + 1 + ], + [ + "166.21", + "0.1", + 1 + ], + [ + "166.22", + "0.203", + 6 + ], + [ + "166.23", + "0.152", + 2 + ], + [ + "166.24", + "3.0575", + 11 + ], + [ + "166.26", + "0.0656", + 2 + ], + [ + "166.27", + "0.01", + 1 + ], + [ + "166.29", + "17.43", + 2 + ], + [ + "166.3", + "0.86", + 1 + ], + [ + "166.33", + "0.6327395", + 10 + ], + [ + "166.46", + "132.536", + 5 + ], + [ + "166.91", + "0.19", + 1 + ], + [ + "167.37", + "0.13", + 1 + ], + [ + "167.6", + "2.214", + 1 + ], + [ + "167.63", + "0.2142", + 1 + ], + [ + "167.66", + "2.64", + 1 + ], + [ + "167.72", + "3.12", + 1 + ], + [ + "167.76", + "0.01", + 1 + ], + [ + "167.78", + "4.2", + 2 + ], + [ + "167.88", + "4.387", + 1 + ], + [ + "167.98", + "4.1482", + 1 + ], + [ + "168.08", + "3.234", + 1 + ], + [ + "168.1", + "1.8", + 1 + ], + [ + "168.54", + "0.013", + 1 + ], + [ + "170.3", + "0.026", + 1 + ], + [ + "171.16", + "0.8", + 1 + ], + [ + "171.25", + "0.11", + 1 + ], + [ + "171.38", + "0.051", + 1 + ], + [ + "171.55", + "0.0138", + 1 + ], + [ + "171.85", + "0.1", + 1 + ], + [ + "174.24", + "3", + 1 + ], + [ + "175.11", + "0.017", + 1 + ], + [ + "179.29", + "0.57", + 1 + ], + [ + "182.58", + "0.09", + 1 + ], + [ + "183.73", + "0.2", + 1 + ], + [ + "186.28", + "0.3", + 1 + ], + [ + "186.39", + "0.39", + 1 + ], + [ + "186.65", + "8", + 1 + ], + [ + "186.83", + "1.25", + 1 + ] + ] +} \ No newline at end of file diff --git a/bxbot-exchanges/src/test/exchange-data/coinbasepro/cancel.json b/bxbot-exchanges/src/test/exchange-data/coinbasepro/cancel.json new file mode 100644 index 000000000..d390bd800 --- /dev/null +++ b/bxbot-exchanges/src/test/exchange-data/coinbasepro/cancel.json @@ -0,0 +1 @@ +["3ecf7a12-fc89-4d3d-baef-f158f80b3bd3"] \ No newline at end of file diff --git a/bxbot-exchanges/src/test/exchange-data/coinbasepro/new_buy_order.json b/bxbot-exchanges/src/test/exchange-data/coinbasepro/new_buy_order.json new file mode 100644 index 000000000..1c47f17cb --- /dev/null +++ b/bxbot-exchanges/src/test/exchange-data/coinbasepro/new_buy_order.json @@ -0,0 +1,16 @@ +{ + "id": "193d2ad9-e671-4d66-9211-7f75f6380231", + "price": "280.18000000", + "size": "0.01000000", + "product_id": "BTC-GBP", + "side": "buy", + "stp": "dc", + "type": "limit", + "time_in_force": "GTC", + "post_only": false, + "created_at": "2015-10-17T14:48:18.873Z", + "fill_fees": "0.0000000000000000", + "filled_size": "0.00000000", + "status": "pending", + "settled": false +} \ No newline at end of file diff --git a/bxbot-exchanges/src/test/exchange-data/coinbasepro/new_sell_order.json b/bxbot-exchanges/src/test/exchange-data/coinbasepro/new_sell_order.json new file mode 100644 index 000000000..2f244ec18 --- /dev/null +++ b/bxbot-exchanges/src/test/exchange-data/coinbasepro/new_sell_order.json @@ -0,0 +1,16 @@ +{ + "id": "693d7ad9-e671-4d66-9911-7f75f6380134", + "price": "290.18000000", + "size": "0.01000000", + "product_id": "BTC-GBP", + "side": "sell", + "stp": "dc", + "type": "limit", + "time_in_force": "GTC", + "post_only": false, + "created_at": "2015-10-17T14:43:18.873Z", + "fill_fees": "0.0000000000000000", + "filled_size": "0.00000000", + "status": "pending", + "settled": false +} \ No newline at end of file diff --git a/bxbot-exchanges/src/test/exchange-data/coinbasepro/orders.json b/bxbot-exchanges/src/test/exchange-data/coinbasepro/orders.json new file mode 100644 index 000000000..14e56d03f --- /dev/null +++ b/bxbot-exchanges/src/test/exchange-data/coinbasepro/orders.json @@ -0,0 +1,66 @@ +[ + { + "id": "cdad7602-f290-41e5-a64d-42a1a20fd02", + "price": "275.00000000", + "size": "0.01000000", + "product_id": "BTC-GBP", + "side": "sell", + "stp": "dc", + "type": "limit", + "time_in_force": "GTC", + "post_only": false, + "created_at": "2015-10-15T21:10:38.193Z", + "fill_fees": "0.0000000000000000", + "filled_size": "0.00500000", + "status": "open", + "settled": false + }, + { + "id": "09cac657-df6c-40ef-97b9-4e64b181dec1", + "price": "270.00000000", + "size": "0.01000000", + "product_id": "BTC-GBP", + "side": "sell", + "stp": "dc", + "type": "limit", + "time_in_force": "GTC", + "post_only": false, + "created_at": "2015-10-15T21:10:10.569Z", + "fill_fees": "0.0000000000000000", + "filled_size": "0.00000000", + "status": "open", + "settled": false + }, + { + "id": "09cac657-df6c-10ef-97b9-4e64b181dec1", + "price": "2001.02", + "size": "0.01000000", + "product_id": "BTC-USD", + "side": "sell", + "stp": "dc", + "type": "limit", + "time_in_force": "GTC", + "post_only": false, + "created_at": "2015-10-15T21:10:10.569Z", + "fill_fees": "0.0000000000000000", + "filled_size": "0.00000000", + "status": "open", + "settled": false + }, + { + "id": "09ca1657-df6c-10ef-97b9-4e64b181dec1", + "price": "341.42000000", + "size": "2.01000000", + "product_id": "ETH-USD", + "side": "sell", + "stp": "dc", + "type": "limit", + "time_in_force": "GTC", + "post_only": false, + "created_at": "2015-10-15T21:10:10.569Z", + "fill_fees": "0.0000000000000000", + "filled_size": "0.00000000", + "status": "open", + "settled": false + } +] \ No newline at end of file diff --git a/bxbot-exchanges/src/test/exchange-data/coinbasepro/stats.json b/bxbot-exchanges/src/test/exchange-data/coinbasepro/stats.json new file mode 100644 index 000000000..755d7b916 --- /dev/null +++ b/bxbot-exchanges/src/test/exchange-data/coinbasepro/stats.json @@ -0,0 +1,8 @@ +{ + "open": "13609.53000000", + "high": "14899.00000000", + "low": "13409.97000000", + "volume": "607.54445656", + "last": "14744.81000000", + "volume_30day": "22412.37849136" +} \ No newline at end of file diff --git a/bxbot-exchanges/src/test/exchange-data/coinbasepro/ticker.json b/bxbot-exchanges/src/test/exchange-data/coinbasepro/ticker.json new file mode 100644 index 000000000..a6f393103 --- /dev/null +++ b/bxbot-exchanges/src/test/exchange-data/coinbasepro/ticker.json @@ -0,0 +1,9 @@ +{ + "trade_id": 29582, + "price": "14744.9", + "size": "2.6108", + "bid":"14744.8", + "ask":"14744.81", + "volume": "607.54445656", + "time": "2017-10-14T19:19:36.604735Z" +} \ No newline at end of file diff --git a/bxbot-exchanges/src/test/java/com/gazbert/bxbot/exchanges/TestCoinbaseProExchangeAdapter.java b/bxbot-exchanges/src/test/java/com/gazbert/bxbot/exchanges/TestCoinbaseProExchangeAdapter.java new file mode 100644 index 000000000..3f6c1875c --- /dev/null +++ b/bxbot-exchanges/src/test/java/com/gazbert/bxbot/exchanges/TestCoinbaseProExchangeAdapter.java @@ -0,0 +1,1224 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 Gareth Jon Lynch + * Copyright (c) 2019 David Huertas + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.gazbert.bxbot.exchanges; + +import static org.easymock.EasyMock.anyObject; +import static org.easymock.EasyMock.anyString; +import static org.easymock.EasyMock.eq; +import static org.easymock.EasyMock.expect; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import com.gazbert.bxbot.exchange.api.AuthenticationConfig; +import com.gazbert.bxbot.exchange.api.ExchangeAdapter; +import com.gazbert.bxbot.exchange.api.ExchangeConfig; +import com.gazbert.bxbot.exchange.api.NetworkConfig; +import com.gazbert.bxbot.exchange.api.OtherConfig; +import com.gazbert.bxbot.trading.api.BalanceInfo; +import com.gazbert.bxbot.trading.api.ExchangeNetworkException; +import com.gazbert.bxbot.trading.api.MarketOrderBook; +import com.gazbert.bxbot.trading.api.OpenOrder; +import com.gazbert.bxbot.trading.api.OrderType; +import com.gazbert.bxbot.trading.api.Ticker; +import com.gazbert.bxbot.trading.api.TradingApiException; +import com.google.gson.GsonBuilder; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.text.DecimalFormat; +import java.time.Instant; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.api.easymock.PowerMock; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +/** + * Tests the behaviour of the COINBASE PRO Exchange Adapter. + * + * @author davidhuertas + */ +@RunWith(PowerMockRunner.class) +@PowerMockIgnore({ + "javax.crypto.*", + "javax.management.*", + "com.sun.org.apache.xerces.*", + "javax.xml.parsers.*", + "org.xml.sax.*", + "org.w3c.dom.*", + "javax.xml.datatype.*" +}) +@PrepareForTest(CoinbaseProExchangeAdapter.class) +public class TestCoinbaseProExchangeAdapter extends AbstractExchangeAdapterTest { + + private static final String BOOK_JSON_RESPONSE = "./src/test/exchange-data/coinbasepro/book.json"; + private static final String ORDERS_JSON_RESPONSE = + "./src/test/exchange-data/coinbasepro/orders.json"; + private static final String ACCOUNTS_JSON_RESPONSE = + "./src/test/exchange-data/coinbasepro/accounts.json"; + private static final String TICKER_JSON_RESPONSE = + "./src/test/exchange-data/coinbasepro/ticker.json"; + private static final String NEW_BUY_ORDER_JSON_RESPONSE = + "./src/test/exchange-data/coinbasepro/new_buy_order.json"; + private static final String NEW_SELL_ORDER_JSON_RESPONSE = + "./src/test/exchange-data/coinbasepro/new_sell_order.json"; + private static final String CANCEL_ORDER_JSON_RESPONSE = + "./src/test/exchange-data/coinbasepro/cancel.json"; + private static final String STATS_JSON_RESPONSE = + "./src/test/exchange-data/coinbasepro/stats.json"; + + private static final String MARKET_ID = "BTC-GBP"; + private static final String ORDER_BOOK_DEPTH_LEVEL = + "2"; // "2" = Top 50 bids and asks (aggregated) + private static final BigDecimal BUY_ORDER_PRICE = new BigDecimal("200.18"); + private static final BigDecimal BUY_ORDER_QUANTITY = new BigDecimal("0.01"); + private static final BigDecimal SELL_ORDER_PRICE = new BigDecimal("300.176"); + private static final BigDecimal SELL_ORDER_QUANTITY = new BigDecimal("0.01"); + private static final String ORDER_ID_TO_CANCEL = "3ecf7a12-fc89-4d3d-baef-f158f80b3bd3"; + + private static final String BOOK = "products/" + MARKET_ID + "/book"; + private static final String ORDERS = "orders"; + private static final String ACCOUNTS = "accounts"; + private static final String TICKER = "products/" + MARKET_ID + "/ticker"; + private static final String NEW_ORDER = "orders"; + private static final String CANCEL_ORDER = "orders/" + ORDER_ID_TO_CANCEL; + private static final String STATS = "products/" + MARKET_ID + "/stats"; + + private static final String MOCKED_CREATE_REQUEST_PARAM_MAP_METHOD = "createRequestParamMap"; + private static final String MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD = + "sendAuthenticatedRequestToExchange"; + private static final String MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD = + "sendPublicRequestToExchange"; + private static final String MOCKED_CREATE_REQUEST_HEADER_MAP_METHOD = "createHeaderParamMap"; + private static final String MOCKED_MAKE_NETWORK_REQUEST_METHOD = "makeNetworkRequest"; + + private static final String PASSPHRASE = "lePassPhrase"; + private static final String KEY = "key123"; + private static final String SECRET = "notGonnaTellYa"; + private static final List nonFatalNetworkErrorCodes = Arrays.asList(502, 503, 504); + private static final List nonFatalNetworkErrorMessages = + Arrays.asList( + "Connection refused", + "Connection reset", + "Remote host closed connection during handshake"); + + private static final String PUBLIC_API_BASE_URL = "https://api.pro.coinbase.com/"; + private static final String AUTHENTICATED_API_URL = PUBLIC_API_BASE_URL; + + private ExchangeConfig exchangeConfig; + private AuthenticationConfig authenticationConfig; + private NetworkConfig networkConfig; + private OtherConfig otherConfig; + + /** Create some exchange config - the TradingEngine would normally do this. */ + @Before + public void setupForEachTest() { + authenticationConfig = PowerMock.createMock(AuthenticationConfig.class); + expect(authenticationConfig.getItem("passphrase")).andReturn(PASSPHRASE); + expect(authenticationConfig.getItem("key")).andReturn(KEY); + expect(authenticationConfig.getItem("secret")).andReturn(SECRET); + + networkConfig = PowerMock.createMock(NetworkConfig.class); + expect(networkConfig.getConnectionTimeout()).andReturn(30); + expect(networkConfig.getNonFatalErrorCodes()).andReturn(nonFatalNetworkErrorCodes); + expect(networkConfig.getNonFatalErrorMessages()).andReturn(nonFatalNetworkErrorMessages); + + otherConfig = PowerMock.createMock(OtherConfig.class); + expect(otherConfig.getItem("buy-fee")).andReturn("0.25"); + expect(otherConfig.getItem("sell-fee")).andReturn("0.25"); + expect(otherConfig.getItem("time-server-bias")).andReturn("82"); + + exchangeConfig = PowerMock.createMock(ExchangeConfig.class); + expect(exchangeConfig.getAuthenticationConfig()).andReturn(authenticationConfig); + expect(exchangeConfig.getNetworkConfig()).andReturn(networkConfig); + expect(exchangeConfig.getOtherConfig()).andReturn(otherConfig); + } + + // -------------------------------------------------------------------------- + // Create Orders tests + // -------------------------------------------------------------------------- + + @Test + @SuppressWarnings("unchecked") + public void testCreateOrderToBuyIsSuccessful() throws Exception { + // Load the canned response from the exchange + final byte[] encoded = Files.readAllBytes(Paths.get(NEW_BUY_ORDER_JSON_RESPONSE)); + final AbstractExchangeAdapter.ExchangeHttpResponse exchangeResponse = + new AbstractExchangeAdapter.ExchangeHttpResponse( + 200, "OK", new String(encoded, StandardCharsets.UTF_8)); + + // Mock out param map, so we can assert the contents passed to the transport + // layer are what we expect. + final Map requestParamMap = PowerMock.createMock(Map.class); + expect( + requestParamMap.put( + "size", + new DecimalFormat("#.########", getDecimalFormatSymbols()) + .format(BUY_ORDER_QUANTITY))) + .andStubReturn(null); + expect( + requestParamMap.put( + "price", + new DecimalFormat("#.##", getDecimalFormatSymbols()).format(BUY_ORDER_PRICE))) + .andStubReturn(null); + expect(requestParamMap.put("side", "buy")).andStubReturn(null); + expect(requestParamMap.put("product_id", MARKET_ID)).andStubReturn(null); + + // Partial mock so we do not send stuff down the wire + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + MOCKED_CREATE_REQUEST_PARAM_MAP_METHOD); + + PowerMock.expectPrivate(exchangeAdapter, MOCKED_CREATE_REQUEST_PARAM_MAP_METHOD) + .andReturn(requestParamMap); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + eq("POST"), + eq(NEW_ORDER), + eq(requestParamMap)) + .andReturn(exchangeResponse); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + final String orderId = + exchangeAdapter.createOrder(MARKET_ID, OrderType.BUY, BUY_ORDER_QUANTITY, BUY_ORDER_PRICE); + assertEquals("193d2ad9-e671-4d66-9211-7f75f6380231", orderId); + + PowerMock.verifyAll(); + } + + @Test + @SuppressWarnings("unchecked") + public void testCreateOrderToSellIsSuccessful() throws Exception { + final byte[] encoded = Files.readAllBytes(Paths.get(NEW_SELL_ORDER_JSON_RESPONSE)); + final AbstractExchangeAdapter.ExchangeHttpResponse exchangeResponse = + new AbstractExchangeAdapter.ExchangeHttpResponse( + 200, "OK", new String(encoded, StandardCharsets.UTF_8)); + + final Map requestParamMap = PowerMock.createMock(Map.class); + expect( + requestParamMap.put( + "size", + new DecimalFormat("#.########", getDecimalFormatSymbols()) + .format(SELL_ORDER_QUANTITY))) + .andStubReturn(null); + expect( + requestParamMap.put( + "price", + new DecimalFormat("#.##", getDecimalFormatSymbols()).format(SELL_ORDER_PRICE))) + .andStubReturn(null); + expect(requestParamMap.put("side", "sell")).andStubReturn(null); + expect(requestParamMap.put("product_id", MARKET_ID)).andStubReturn(null); + + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + MOCKED_CREATE_REQUEST_PARAM_MAP_METHOD); + + PowerMock.expectPrivate(exchangeAdapter, MOCKED_CREATE_REQUEST_PARAM_MAP_METHOD) + .andReturn(requestParamMap); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + eq("POST"), + eq(NEW_ORDER), + eq(requestParamMap)) + .andReturn(exchangeResponse); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + final String orderId = + exchangeAdapter.createOrder( + MARKET_ID, OrderType.SELL, SELL_ORDER_QUANTITY, SELL_ORDER_PRICE); + assertEquals("693d7ad9-e671-4d66-9911-7f75f6380134", orderId); + + PowerMock.verifyAll(); + } + + @Test(expected = ExchangeNetworkException.class) + public void testCreateOrderHandlesExchangeNetworkException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + eq("POST"), + eq(NEW_ORDER), + anyObject(Map.class)) + .andThrow( + new ExchangeNetworkException( + " When it comes to the safety of these people, there's me and " + + "then there's God, understand?")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.createOrder(MARKET_ID, OrderType.SELL, SELL_ORDER_QUANTITY, SELL_ORDER_PRICE); + PowerMock.verifyAll(); + } + + @Test(expected = TradingApiException.class) + public void testCreateOrderHandlesUnexpectedException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + eq("POST"), + eq(NEW_ORDER), + anyObject(Map.class)) + .andThrow( + new IllegalArgumentException( + " We all see what we want to see. Coffey looks and he sees Russians. He sees hate " + + "and fear. You have to look with better eyes than that")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.createOrder(MARKET_ID, OrderType.BUY, BUY_ORDER_QUANTITY, BUY_ORDER_PRICE); + PowerMock.verifyAll(); + } + + // -------------------------------------------------------------------------- + // Cancel Order tests + // -------------------------------------------------------------------------- + + @Test + public void testCancelOrderIsSuccessful() throws Exception { + final byte[] encoded = Files.readAllBytes(Paths.get(CANCEL_ORDER_JSON_RESPONSE)); + final AbstractExchangeAdapter.ExchangeHttpResponse exchangeResponse = + new AbstractExchangeAdapter.ExchangeHttpResponse( + 200, "OK", new String(encoded, StandardCharsets.UTF_8)); + + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD); + + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + eq("DELETE"), + eq(CANCEL_ORDER), + eq(null)) + .andReturn(exchangeResponse); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + // marketId arg not needed for cancelling orders on this exchange. + final boolean success = exchangeAdapter.cancelOrder(ORDER_ID_TO_CANCEL, null); + assertTrue(success); + PowerMock.verifyAll(); + } + + @Test(expected = ExchangeNetworkException.class) + public void testCancelOrderHandlesExchangeNetworkException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD); + + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + eq("DELETE"), + eq(CANCEL_ORDER), + eq(null)) + .andThrow( + new ExchangeNetworkException( + "We don't need them. We can't trust them. We may have to take steps." + + " We're gonna have to take steps.")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + // marketId arg not needed for cancelling orders on this exchange. + exchangeAdapter.cancelOrder(ORDER_ID_TO_CANCEL, null); + PowerMock.verifyAll(); + } + + @Test(expected = TradingApiException.class) + public void testCancelOrderHandlesUnexpectedException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD); + + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + eq("DELETE"), + eq(CANCEL_ORDER), + eq(null)) + .andThrow( + new IllegalStateException( + "Fluid breathing system, we just got it. You use it when you go really deep.")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + // marketId arg not needed for cancelling orders on this exchange. + exchangeAdapter.cancelOrder(ORDER_ID_TO_CANCEL, null); + PowerMock.verifyAll(); + } + + // -------------------------------------------------------------------------- + // Get Your Open Orders tests + // -------------------------------------------------------------------------- + + @Test + public void testGettingYourOpenOrdersSuccessfully() throws Exception { + final byte[] encoded = Files.readAllBytes(Paths.get(ORDERS_JSON_RESPONSE)); + final AbstractExchangeAdapter.ExchangeHttpResponse exchangeResponse = + new AbstractExchangeAdapter.ExchangeHttpResponse( + 200, "OK", new String(encoded, StandardCharsets.UTF_8)); + + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD); + + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + eq("GET"), + eq(ORDERS), + eq(null)) + .andReturn(exchangeResponse); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + final List openOrders = exchangeAdapter.getYourOpenOrders(MARKET_ID); + + // assert some key stuff; we're not testing GSON here. + assertEquals(2, openOrders.size()); + assertEquals(MARKET_ID, openOrders.get(0).getMarketId()); + assertEquals("cdad7602-f290-41e5-a64d-42a1a20fd02", openOrders.get(0).getId()); + assertSame(OrderType.SELL, openOrders.get(0).getType()); + assertEquals( + openOrders.get(0).getCreationDate(), Date.from(Instant.parse("2015-10-15T21:10:38.193Z"))); + assertEquals(0, openOrders.get(0).getPrice().compareTo(new BigDecimal("275.00000000"))); + assertEquals( + 0, openOrders.get(0).getOriginalQuantity().compareTo(new BigDecimal("0.01000000"))); + assertEquals(0, openOrders.get(0).getQuantity().compareTo(new BigDecimal("0.00500000"))); + assertEquals( + 0, + openOrders + .get(0) + .getTotal() + .compareTo( + openOrders.get(0).getPrice().multiply(openOrders.get(0).getOriginalQuantity()))); + + PowerMock.verifyAll(); + } + + @Test(expected = ExchangeNetworkException.class) + public void testGettingYourOpenOrdersHandlesExchangeNetworkException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + eq("GET"), + eq(ORDERS), + eq(null)) + .andThrow(new ExchangeNetworkException("Bond. James Bond.")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.getYourOpenOrders(MARKET_ID); + PowerMock.verifyAll(); + } + + @Test(expected = TradingApiException.class) + public void testGettingYourOpenOrdersHandlesUnexpectedException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + eq("GET"), + eq(ORDERS), + eq(null)) + .andThrow( + new IllegalStateException( + "All those moments will be lost in time... like tears in rain.")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.getYourOpenOrders(MARKET_ID); + PowerMock.verifyAll(); + } + + // -------------------------------------------------------------------------- + // Get Market Orders tests + // -------------------------------------------------------------------------- + + @Test + @SuppressWarnings("unchecked") + public void testGettingMarketOrders() throws Exception { + final byte[] encoded = Files.readAllBytes(Paths.get(BOOK_JSON_RESPONSE)); + final AbstractExchangeAdapter.ExchangeHttpResponse exchangeResponse = + new AbstractExchangeAdapter.ExchangeHttpResponse( + 200, "OK", new String(encoded, StandardCharsets.UTF_8)); + + final Map requestParamMap = PowerMock.createMock(Map.class); + expect(requestParamMap.put("level", ORDER_BOOK_DEPTH_LEVEL)).andStubReturn(null); + + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, + MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD, + MOCKED_CREATE_REQUEST_PARAM_MAP_METHOD); + + PowerMock.expectPrivate(exchangeAdapter, MOCKED_CREATE_REQUEST_PARAM_MAP_METHOD) + .andReturn(requestParamMap); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD, + eq(BOOK), + eq(requestParamMap)) + .andReturn(exchangeResponse); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + final MarketOrderBook marketOrderBook = exchangeAdapter.getMarketOrders(MARKET_ID); + + // assert some key stuff; we're not testing GSON here. + assertEquals(MARKET_ID, marketOrderBook.getMarketId()); + + final BigDecimal buyPrice = new BigDecimal("165.87"); + final BigDecimal buyQuantity = new BigDecimal("16.2373"); + final BigDecimal buyTotal = buyPrice.multiply(buyQuantity); + + assertEquals(50, marketOrderBook.getBuyOrders().size()); + assertSame(OrderType.BUY, marketOrderBook.getBuyOrders().get(0).getType()); + assertEquals(0, marketOrderBook.getBuyOrders().get(0).getPrice().compareTo(buyPrice)); + assertEquals(0, marketOrderBook.getBuyOrders().get(0).getQuantity().compareTo(buyQuantity)); + assertEquals(0, marketOrderBook.getBuyOrders().get(0).getTotal().compareTo(buyTotal)); + + final BigDecimal sellPrice = new BigDecimal("165.96"); + final BigDecimal sellQuantity = new BigDecimal("24.31"); + final BigDecimal sellTotal = sellPrice.multiply(sellQuantity); + + assertEquals(50, marketOrderBook.getSellOrders().size()); + assertSame(OrderType.SELL, marketOrderBook.getSellOrders().get(0).getType()); + assertEquals(0, marketOrderBook.getSellOrders().get(0).getPrice().compareTo(sellPrice)); + assertEquals(0, marketOrderBook.getSellOrders().get(0).getQuantity().compareTo(sellQuantity)); + assertEquals(0, marketOrderBook.getSellOrders().get(0).getTotal().compareTo(sellTotal)); + + PowerMock.verifyAll(); + } + + @Test(expected = ExchangeNetworkException.class) + public void testGettingMarketOrdersHandlesExchangeNetworkException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD); + + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD, + eq(BOOK), + anyObject(Map.class)) + .andThrow(new ExchangeNetworkException("Re-verify our range to target... one ping only.")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.getMarketOrders(MARKET_ID); + PowerMock.verifyAll(); + } + + @Test(expected = TradingApiException.class) + public void testGettingMarketOrdersHandlesUnexpectedException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD); + + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD, + eq(BOOK), + anyObject(Map.class)) + .andThrow( + new IllegalArgumentException( + "Mr. Ambassador, you have nearly a hundred naval vessels operating in the " + + "North Atlantic right now. Your aircraft has dropped enough sonar buoys " + + "so that a man could walk from Greenland to Iceland to Scotland without " + + "getting his feet wet. Now, shall we dispense with the bull?")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.getMarketOrders(MARKET_ID); + PowerMock.verifyAll(); + } + + // -------------------------------------------------------------------------- + // Get Latest Market Price tests + // -------------------------------------------------------------------------- + + @Test + public void testGettingLatestMarketPriceSuccessfully() throws Exception { + final byte[] encoded = Files.readAllBytes(Paths.get(TICKER_JSON_RESPONSE)); + final AbstractExchangeAdapter.ExchangeHttpResponse exchangeResponse = + new AbstractExchangeAdapter.ExchangeHttpResponse( + 200, "OK", new String(encoded, StandardCharsets.UTF_8)); + + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD); + + PowerMock.expectPrivate( + exchangeAdapter, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD, eq(TICKER), eq(null)) + .andReturn(exchangeResponse); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + final BigDecimal latestMarketPrice = + exchangeAdapter.getLatestMarketPrice(MARKET_ID).setScale(8, RoundingMode.HALF_UP); + assertEquals(0, latestMarketPrice.compareTo(new BigDecimal("14744.9"))); + + PowerMock.verifyAll(); + } + + @Test(expected = ExchangeNetworkException.class) + public void testGettingLatestMarketPriceHandlesExchangeNetworkException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD); + PowerMock.expectPrivate( + exchangeAdapter, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD, eq(TICKER), eq(null)) + .andThrow( + new ExchangeNetworkException("I need your clothes, your boots and your motorcycle.")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.getLatestMarketPrice(MARKET_ID); + PowerMock.verifyAll(); + } + + @Test(expected = TradingApiException.class) + public void testGettingLatestMarketPriceHandlesUnexpectedException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD); + PowerMock.expectPrivate( + exchangeAdapter, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD, eq(TICKER), eq(null)) + .andThrow(new IllegalArgumentException("Come with me if you want to live.")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.getLatestMarketPrice(MARKET_ID); + PowerMock.verifyAll(); + } + + // -------------------------------------------------------------------------- + // Get Balance Info tests + // -------------------------------------------------------------------------- + + @Test + public void testGettingBalanceInfoSuccessfully() throws Exception { + final byte[] encoded = Files.readAllBytes(Paths.get(ACCOUNTS_JSON_RESPONSE)); + final AbstractExchangeAdapter.ExchangeHttpResponse exchangeResponse = + new AbstractExchangeAdapter.ExchangeHttpResponse( + 200, "OK", new String(encoded, StandardCharsets.UTF_8)); + + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + eq("GET"), + eq(ACCOUNTS), + eq(null)) + .andReturn(exchangeResponse); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + final BalanceInfo balanceInfo = exchangeAdapter.getBalanceInfo(); + + // assert some key stuff; we're not testing GSON here. + assertEquals( + 0, + balanceInfo + .getBalancesAvailable() + .get("BTC") + .compareTo(new BigDecimal("100.0000000000000004"))); + assertEquals( + 0, + balanceInfo + .getBalancesAvailable() + .get("GBP") + .compareTo(new BigDecimal("501.0100000000000001"))); + assertEquals(0, balanceInfo.getBalancesAvailable().get("EUR").compareTo(new BigDecimal("0"))); + + assertEquals( + 0, + balanceInfo + .getBalancesOnHold() + .get("BTC") + .compareTo(new BigDecimal("100.0000000000000005"))); + assertEquals( + 0, + balanceInfo + .getBalancesOnHold() + .get("GBP") + .compareTo(new BigDecimal("499.9900000000000002"))); + assertEquals(0, balanceInfo.getBalancesOnHold().get("EUR").compareTo(new BigDecimal("0"))); + + PowerMock.verifyAll(); + } + + @Test(expected = ExchangeNetworkException.class) + public void testGettingBalanceInfoHandlesExchangeNetworkException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + eq("GET"), + eq(ACCOUNTS), + eq(null)) + .andThrow( + new ExchangeNetworkException( + "Three o'clock is always too late or too early for anything you want to do.")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.getBalanceInfo(); + PowerMock.verifyAll(); + } + + @Test(expected = TradingApiException.class) + public void testGettingBalanceInfoHandlesUnexpectedException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_SEND_AUTHENTICATED_REQUEST_TO_EXCHANGE_METHOD, + eq("GET"), + eq(ACCOUNTS), + eq(null)) + .andThrow( + new IllegalStateException( + "There is a time for many words, and there is also a time for sleep.")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.getBalanceInfo(); + PowerMock.verifyAll(); + } + + // -------------------------------------------------------------------------- + // Get Ticker tests + // -------------------------------------------------------------------------- + + @Test + public void testGettingTickerSuccessfully() throws Exception { + final byte[] encodedTicker = Files.readAllBytes(Paths.get(TICKER_JSON_RESPONSE)); + final AbstractExchangeAdapter.ExchangeHttpResponse tickerExchangeResponse = + new AbstractExchangeAdapter.ExchangeHttpResponse( + 200, "OK", new String(encodedTicker, StandardCharsets.UTF_8)); + + final byte[] encodedStats = Files.readAllBytes(Paths.get(STATS_JSON_RESPONSE)); + final AbstractExchangeAdapter.ExchangeHttpResponse statsExchangeResponse = + new AbstractExchangeAdapter.ExchangeHttpResponse( + 200, "OK", new String(encodedStats, StandardCharsets.UTF_8)); + + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD); + + PowerMock.expectPrivate( + exchangeAdapter, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD, eq(TICKER), eq(null)) + .andReturn(tickerExchangeResponse); + PowerMock.expectPrivate( + exchangeAdapter, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD, eq(STATS), eq(null)) + .andReturn(statsExchangeResponse); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + final Ticker ticker = exchangeAdapter.getTicker(MARKET_ID); + + assertEquals(0, ticker.getLast().compareTo(new BigDecimal("14744.9"))); + assertEquals(0, ticker.getAsk().compareTo(new BigDecimal("14744.81"))); + assertEquals(0, ticker.getBid().compareTo(new BigDecimal("14744.8"))); + assertEquals(0, ticker.getHigh().compareTo(new BigDecimal("14899.00000000"))); + assertEquals(0, ticker.getLow().compareTo(new BigDecimal("13409.97000000"))); + assertEquals(0, ticker.getOpen().compareTo(new BigDecimal("13609.53000000"))); + assertEquals(0, ticker.getVolume().compareTo(new BigDecimal("607.54445656"))); + assertNull(ticker.getVwap()); // not provided by COINBASE PRO + assertEquals(1508008776604L, (long) ticker.getTimestamp()); + + PowerMock.verifyAll(); + } + + @Test(expected = ExchangeNetworkException.class) + public void testGettingTickerHandlesExchangeNetworkException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD); + PowerMock.expectPrivate( + exchangeAdapter, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD, eq(TICKER), eq(null)) + .andThrow( + new ExchangeNetworkException( + "Listen, Herr Mac, I don't know what kind of people you're used to dealing with, " + + "but nobody tells me what to do in my place.")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.getTicker(MARKET_ID); + PowerMock.verifyAll(); + } + + @Test(expected = TradingApiException.class) + public void testGettingTickerHandlesUnexpectedException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD); + PowerMock.expectPrivate( + exchangeAdapter, MOCKED_SEND_PUBLIC_REQUEST_TO_EXCHANGE_METHOD, eq(TICKER), eq(null)) + .andThrow( + new IllegalArgumentException( + "Indiana Jones. I always knew some day you'd come " + + "walking back through my door. I never doubted that. Something made it " + + "inevitable. So, what are you doing here in Nepal?")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.getTicker(MARKET_ID); + PowerMock.verifyAll(); + } + + // -------------------------------------------------------------------------- + // Non Exchange visiting tests + // -------------------------------------------------------------------------- + + @Test + public void testGettingExchangeSellingFeeIsAsExpected() { + PowerMock.replayAll(); + final CoinbaseProExchangeAdapter exchangeAdapter = new CoinbaseProExchangeAdapter(); + exchangeAdapter.init(exchangeConfig); + + final BigDecimal sellPercentageFee = + exchangeAdapter.getPercentageOfSellOrderTakenForExchangeFee(MARKET_ID); + assertEquals(0, sellPercentageFee.compareTo(new BigDecimal("0.0025"))); + PowerMock.verifyAll(); + } + + @Test + public void testGettingExchangeBuyingFeeIsAsExpected() { + PowerMock.replayAll(); + final CoinbaseProExchangeAdapter exchangeAdapter = new CoinbaseProExchangeAdapter(); + exchangeAdapter.init(exchangeConfig); + + final BigDecimal buyPercentageFee = + exchangeAdapter.getPercentageOfBuyOrderTakenForExchangeFee(MARKET_ID); + assertEquals(0, buyPercentageFee.compareTo(new BigDecimal("0.0025"))); + PowerMock.verifyAll(); + } + + @Test + public void testGettingImplNameIsAsExpected() { + PowerMock.replayAll(); + final CoinbaseProExchangeAdapter exchangeAdapter = new CoinbaseProExchangeAdapter(); + exchangeAdapter.init(exchangeConfig); + + assertEquals("COINBASE PRO REST API v1", exchangeAdapter.getImplName()); + PowerMock.verifyAll(); + } + + // -------------------------------------------------------------------------- + // Initialisation tests + // -------------------------------------------------------------------------- + + @Test + public void testExchangeAdapterInitialisesSuccessfully() { + PowerMock.replayAll(); + + final CoinbaseProExchangeAdapter exchangeAdapter = new CoinbaseProExchangeAdapter(); + exchangeAdapter.init(exchangeConfig); + assertNotNull(exchangeAdapter); + + PowerMock.verifyAll(); + } + + @Test(expected = IllegalArgumentException.class) + public void testExchangeAdapterThrowsExceptionIfPassphraseConfigIsMissing() { + PowerMock.reset(authenticationConfig); + expect(authenticationConfig.getItem("passphrase")).andReturn(null); + expect(authenticationConfig.getItem("key")).andReturn("your_client_key"); + expect(authenticationConfig.getItem("secret")).andReturn("your_client_secret"); + PowerMock.replayAll(); + + final ExchangeAdapter exchangeAdapter = new CoinbaseProExchangeAdapter(); + exchangeAdapter.init(exchangeConfig); + + PowerMock.verifyAll(); + } + + @Test(expected = IllegalArgumentException.class) + public void testExchangeAdapterThrowsExceptionIfPublicKeyConfigIsMissing() { + PowerMock.reset(authenticationConfig); + expect(authenticationConfig.getItem("passphrase")).andReturn("your_passphrase"); + expect(authenticationConfig.getItem("key")).andReturn(null); + expect(authenticationConfig.getItem("secret")).andReturn("your_client_secret"); + PowerMock.replayAll(); + + final ExchangeAdapter exchangeAdapter = new CoinbaseProExchangeAdapter(); + exchangeAdapter.init(exchangeConfig); + + PowerMock.verifyAll(); + } + + @Test(expected = IllegalArgumentException.class) + public void testExchangeAdapterThrowsExceptionIfSecretConfigIsMissing() { + PowerMock.reset(authenticationConfig); + expect(authenticationConfig.getItem("passphrase")).andReturn("your_passphrase"); + expect(authenticationConfig.getItem("key")).andReturn("your_client_key"); + expect(authenticationConfig.getItem("secret")).andReturn(null); + PowerMock.replayAll(); + + final ExchangeAdapter exchangeAdapter = new CoinbaseProExchangeAdapter(); + exchangeAdapter.init(exchangeConfig); + + PowerMock.verifyAll(); + } + + @Test(expected = IllegalArgumentException.class) + public void testExchangeAdapterThrowsExceptionIfBuyFeeIsMissing() { + PowerMock.reset(otherConfig); + expect(otherConfig.getItem("buy-fee")).andReturn(""); + expect(otherConfig.getItem("sell-fee")).andReturn("0.25"); + PowerMock.replayAll(); + + final ExchangeAdapter exchangeAdapter = new CoinbaseProExchangeAdapter(); + exchangeAdapter.init(exchangeConfig); + + PowerMock.verifyAll(); + } + + @Test(expected = IllegalArgumentException.class) + public void testExchangeAdapterThrowsExceptionIfSellFeeIsMissing() { + PowerMock.reset(otherConfig); + expect(otherConfig.getItem("buy-fee")).andReturn("0.25"); + expect(otherConfig.getItem("sell-fee")).andReturn(""); + + PowerMock.replayAll(); + final ExchangeAdapter exchangeAdapter = new CoinbaseProExchangeAdapter(); + exchangeAdapter.init(exchangeConfig); + + PowerMock.verifyAll(); + } + + @Test(expected = IllegalArgumentException.class) + public void testExchangeAdapterThrowsExceptionIfTimeoutConfigIsMissing() { + PowerMock.reset(networkConfig); + expect(networkConfig.getConnectionTimeout()).andReturn(0); + PowerMock.replayAll(); + + final ExchangeAdapter exchangeAdapter = new CoinbaseProExchangeAdapter(); + exchangeAdapter.init(exchangeConfig); + + PowerMock.verifyAll(); + } + + // -------------------------------------------------------------------------- + // Request sending tests + // + // "The rabbit-hole went straight on like a tunnel for some way, and then dipped suddenly down, + // so suddenly that Alice had not a moment to think about stopping herself before she found + // herself falling down what seemed to be a very deep well..." + // -------------------------------------------------------------------------- + + @Test + public void testSendingPublicRequestToExchangeSuccessfully() throws Exception { + final byte[] encoded = Files.readAllBytes(Paths.get(TICKER_JSON_RESPONSE)); + final AbstractExchangeAdapter.ExchangeHttpResponse exchangeResponse = + new AbstractExchangeAdapter.ExchangeHttpResponse( + 200, "OK", new String(encoded, StandardCharsets.UTF_8)); + + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_MAKE_NETWORK_REQUEST_METHOD); + + final URL url = new URL(PUBLIC_API_BASE_URL + TICKER); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_MAKE_NETWORK_REQUEST_METHOD, + eq(url), + eq("GET"), + eq(null), + eq(new HashMap<>())) + .andReturn(exchangeResponse); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + final BigDecimal lastMarketPrice = exchangeAdapter.getLatestMarketPrice(MARKET_ID); + assertEquals(0, lastMarketPrice.compareTo(new BigDecimal("14744.9"))); + + PowerMock.verifyAll(); + } + + @Test(expected = ExchangeNetworkException.class) + public void testSendingPublicRequestToExchangeHandlesExchangeNetworkException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_MAKE_NETWORK_REQUEST_METHOD); + + final URL url = new URL(PUBLIC_API_BASE_URL + TICKER); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_MAKE_NETWORK_REQUEST_METHOD, + eq(url), + eq("GET"), + eq(null), + eq(new HashMap<>())) + .andThrow( + new ExchangeNetworkException("One wrong note eventually ruins the entire symphony.")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.getLatestMarketPrice(MARKET_ID); + + PowerMock.verifyAll(); + } + + @Test(expected = TradingApiException.class) + public void testSendingPublicRequestToExchangeHandlesTradingApiException() throws Exception { + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, MOCKED_MAKE_NETWORK_REQUEST_METHOD); + + final URL url = new URL(PUBLIC_API_BASE_URL + TICKER); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_MAKE_NETWORK_REQUEST_METHOD, + eq(url), + eq("GET"), + eq(null), + eq(new HashMap<>())) + .andThrow(new TradingApiException("Look on my works, ye Mighty, and despair.")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.getLatestMarketPrice(MARKET_ID); + + PowerMock.verifyAll(); + } + + @Test + @SuppressWarnings("unchecked") + public void testSendingAuthenticatedRequestToExchangeSuccessfully() throws Exception { + final byte[] encoded = Files.readAllBytes(Paths.get(NEW_SELL_ORDER_JSON_RESPONSE)); + final AbstractExchangeAdapter.ExchangeHttpResponse exchangeResponse = + new AbstractExchangeAdapter.ExchangeHttpResponse( + 200, "OK", new String(encoded, StandardCharsets.UTF_8)); + + final Map requestParamMap = new HashMap<>(); + requestParamMap.put( + "size", + new DecimalFormat("#.########", getDecimalFormatSymbols()).format(SELL_ORDER_QUANTITY)); + requestParamMap.put( + "price", new DecimalFormat("#.##", getDecimalFormatSymbols()).format(SELL_ORDER_PRICE)); + requestParamMap.put("side", "sell"); + requestParamMap.put("product_id", MARKET_ID); + + final Map requestHeaderMap = PowerMock.createPartialMock(HashMap.class, "put"); + expect(requestHeaderMap.put("Content-Type", "application/json")).andStubReturn(null); + expect(requestHeaderMap.put(eq("CB-ACCESS-KEY"), eq(KEY))).andStubReturn(null); + expect(requestHeaderMap.put(eq("CB-ACCESS-SIGN"), anyString())).andStubReturn(null); + expect(requestHeaderMap.put(eq("CB-ACCESS-TIMESTAMP"), anyString())).andStubReturn(null); + expect(requestHeaderMap.put(eq("CB-ACCESS-PASSPHRASE"), eq(PASSPHRASE))).andStubReturn(null); + PowerMock.replay(requestHeaderMap); // map needs to be in play early + + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, + MOCKED_MAKE_NETWORK_REQUEST_METHOD, + MOCKED_CREATE_REQUEST_HEADER_MAP_METHOD); + PowerMock.expectPrivate(exchangeAdapter, MOCKED_CREATE_REQUEST_HEADER_MAP_METHOD) + .andReturn(requestHeaderMap); + + final URL url = new URL(AUTHENTICATED_API_URL + NEW_ORDER); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_MAKE_NETWORK_REQUEST_METHOD, + eq(url), + eq("POST"), + eq(new GsonBuilder().create().toJson(requestParamMap)), + eq(requestHeaderMap)) + .andReturn(exchangeResponse); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + final String orderId = + exchangeAdapter.createOrder( + MARKET_ID, OrderType.SELL, SELL_ORDER_QUANTITY, SELL_ORDER_PRICE); + assertEquals("693d7ad9-e671-4d66-9911-7f75f6380134", orderId); + + PowerMock.verifyAll(); + } + + @Test(expected = ExchangeNetworkException.class) + @SuppressWarnings("unchecked") + public void testSendingAuthenticatedRequestToExchangeHandlesExchangeNetworkException() + throws Exception { + final Map requestParamMap = new HashMap<>(); + requestParamMap.put( + "size", + new DecimalFormat("#.########", getDecimalFormatSymbols()).format(SELL_ORDER_QUANTITY)); + requestParamMap.put( + "price", new DecimalFormat("#.##", getDecimalFormatSymbols()).format(SELL_ORDER_PRICE)); + requestParamMap.put("side", "sell"); + requestParamMap.put("product_id", MARKET_ID); + + final Map requestHeaderMap = PowerMock.createPartialMock(HashMap.class, "put"); + expect(requestHeaderMap.put("Content-Type", "application/json")).andStubReturn(null); + expect(requestHeaderMap.put(eq("CB-ACCESS-KEY"), eq(KEY))).andStubReturn(null); + expect(requestHeaderMap.put(eq("CB-ACCESS-SIGN"), anyString())).andStubReturn(null); + expect(requestHeaderMap.put(eq("CB-ACCESS-TIMESTAMP"), anyString())).andStubReturn(null); + expect(requestHeaderMap.put(eq("CB-ACCESS-PASSPHRASE"), eq(PASSPHRASE))).andStubReturn(null); + PowerMock.replay(requestHeaderMap); // map needs to be in play early + + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, + MOCKED_MAKE_NETWORK_REQUEST_METHOD, + MOCKED_CREATE_REQUEST_HEADER_MAP_METHOD); + PowerMock.expectPrivate(exchangeAdapter, MOCKED_CREATE_REQUEST_HEADER_MAP_METHOD) + .andReturn(requestHeaderMap); + + final URL url = new URL(AUTHENTICATED_API_URL + NEW_ORDER); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_MAKE_NETWORK_REQUEST_METHOD, + eq(url), + eq("POST"), + eq(new GsonBuilder().create().toJson(requestParamMap)), + eq(requestHeaderMap)) + .andThrow( + new ExchangeNetworkException( + "Allow me then a moment to consider. You seek your creator. " + + "I am looking at mine. I will serve you, yet you're human. " + + "You will die, I will not.")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.createOrder(MARKET_ID, OrderType.SELL, SELL_ORDER_QUANTITY, SELL_ORDER_PRICE); + + PowerMock.verifyAll(); + } + + @Test(expected = TradingApiException.class) + @SuppressWarnings("unchecked") + public void testSendingAuthenticatedRequestToExchangeHandlesTradingApiException() + throws Exception { + final Map requestParamMap = new HashMap<>(); + requestParamMap.put( + "size", + new DecimalFormat("#.########", getDecimalFormatSymbols()).format(SELL_ORDER_QUANTITY)); + requestParamMap.put( + "price", new DecimalFormat("#.##", getDecimalFormatSymbols()).format(SELL_ORDER_PRICE)); + requestParamMap.put("side", "sell"); + requestParamMap.put("product_id", MARKET_ID); + + final Map requestHeaderMap = PowerMock.createPartialMock(HashMap.class, "put"); + expect(requestHeaderMap.put("Content-Type", "application/json")).andStubReturn(null); + expect(requestHeaderMap.put(eq("CB-ACCESS-KEY"), eq(KEY))).andStubReturn(null); + expect(requestHeaderMap.put(eq("CB-ACCESS-SIGN"), anyString())).andStubReturn(null); + expect(requestHeaderMap.put(eq("CB-ACCESS-TIMESTAMP"), anyString())).andStubReturn(null); + expect(requestHeaderMap.put(eq("CB-ACCESS-PASSPHRASE"), eq(PASSPHRASE))).andStubReturn(null); + PowerMock.replay(requestHeaderMap); // map needs to be in play early + + final CoinbaseProExchangeAdapter exchangeAdapter = + PowerMock.createPartialMockAndInvokeDefaultConstructor( + CoinbaseProExchangeAdapter.class, + MOCKED_MAKE_NETWORK_REQUEST_METHOD, + MOCKED_CREATE_REQUEST_HEADER_MAP_METHOD); + PowerMock.expectPrivate(exchangeAdapter, MOCKED_CREATE_REQUEST_HEADER_MAP_METHOD) + .andReturn(requestHeaderMap); + + final URL url = new URL(AUTHENTICATED_API_URL + NEW_ORDER); + PowerMock.expectPrivate( + exchangeAdapter, + MOCKED_MAKE_NETWORK_REQUEST_METHOD, + eq(url), + eq("POST"), + eq(new GsonBuilder().create().toJson(requestParamMap)), + eq(requestHeaderMap)) + .andThrow(new TradingApiException("When you close your eyes do you dream of me?")); + + PowerMock.replayAll(); + exchangeAdapter.init(exchangeConfig); + + exchangeAdapter.createOrder(MARKET_ID, OrderType.SELL, SELL_ORDER_QUANTITY, SELL_ORDER_PRICE); + + PowerMock.verifyAll(); + } +} diff --git a/config/samples/bitfinex/engine.yaml b/config/samples/bitfinex/engine.yaml index 25f525b0c..30240dcc0 100644 --- a/config/samples/bitfinex/engine.yaml +++ b/config/samples/bitfinex/engine.yaml @@ -17,7 +17,7 @@ engine: # This must be set to prevent catastrophic loss on the exchange. # This is normally the currency you intend to hold a long position in. It should be set to the currency short code for the - # wallet, e.g. BTC, LTC, USD. This value can be case sensitive for some exchanges - check the Exchange Adapter documentation. + # wallet, e.g. BTC, LTC, USD. This value can be case-sensitive for some exchanges - check the Exchange Adapter documentation. emergencyStopCurrency: USD # This must be set to prevent a catastrophic loss on the exchange. @@ -26,7 +26,7 @@ engine: # Manual intervention is then required to restart the bot. You can set this value to 0 to override this check. emergencyStopBalance: 50 - # The is the interval in seconds that the Trading Engine will wait/sleep before executing + # This is the interval in seconds that the Trading Engine will wait/sleep before executing # the next trade cycle. The minimum value is 1 second. Some exchanges allow you to hit them harder than others. However, # while their API documentation might say one thing, the reality is you might get socket timeouts and 5XX responses if you # hit it too hard - you cannot perform ultra low latency trading over the public internet ;-) diff --git a/config/samples/bitstamp/engine.yaml b/config/samples/bitstamp/engine.yaml index 2bc59b809..7464d8685 100644 --- a/config/samples/bitstamp/engine.yaml +++ b/config/samples/bitstamp/engine.yaml @@ -17,7 +17,7 @@ engine: # This must be set to prevent catastrophic loss on the exchange. # This is normally the currency you intend to hold a long position in. It should be set to the currency short code for the - # wallet, e.g. BTC, LTC, USD. This value can be case sensitive for some exchanges - check the Exchange Adapter documentation. + # wallet, e.g. BTC, LTC, USD. This value can be case-sensitive for some exchanges - check the Exchange Adapter documentation. emergencyStopCurrency: USD # This must be set to prevent a catastrophic loss on the exchange. @@ -26,7 +26,7 @@ engine: # Manual intervention is then required to restart the bot. You can set this value to 0 to override this check. emergencyStopBalance: 50 - # The is the interval in seconds that the Trading Engine will wait/sleep before executing + # This is the interval in seconds that the Trading Engine will wait/sleep before executing # the next trade cycle. The minimum value is 1 second. Some exchanges allow you to hit them harder than others. However, # while their API documentation might say one thing, the reality is you might get socket timeouts and 5XX responses if you # hit it too hard - you cannot perform ultra low latency trading over the public internet ;-) diff --git a/config/samples/coinbase/email-alerts.yaml b/config/samples/coinbase/email-alerts.yaml new file mode 100644 index 000000000..d591e38e0 --- /dev/null +++ b/config/samples/coinbase/email-alerts.yaml @@ -0,0 +1,24 @@ +############################################################################################ +# Email Alerts YAML config. +# +# - All fields are mandatory unless stated otherwise. +# - Only 1 emailAlerts block can be specified. +# - The email is sent using TLS. +# - The indentation levels are significant in YAML: https://en.wikipedia.org/wiki/YAML +# +# Sample config for using a Gmail account to send the email is shown below. +############################################################################################ +--- +emailAlerts: + + # If set to true, the bot will load the smtpConfig, and enable email alerts. + enabled: false + + # Set your SMTP details here. + smtpConfig: + host: smtp.gmail.com + tlsPort: 587 + accountUsername: your.account.username@gmail.com + accountPassword: your.account.password + fromAddress: from.addr@gmail.com + toAddress: to.addr@gmail.com diff --git a/config/samples/coinbase/engine.yaml b/config/samples/coinbase/engine.yaml new file mode 100644 index 000000000..57e095393 --- /dev/null +++ b/config/samples/coinbase/engine.yaml @@ -0,0 +1,34 @@ +############################################################################################ +# Trading Engine YAML config. +# +# - All fields are mandatory unless stated otherwise. +# - Only 1 engine block can be specified. +# - The indentation levels are significant in YAML: https://en.wikipedia.org/wiki/YAML +############################################################################################ +--- +engine: + + # A unique identifier for the bot. Value must be an alphanumeric string. + # Underscores and dashes are also permitted. + botId: my-coinbase-advanced-trade-bot-1 + + # A friendly name for the bot. Value must be an alphanumeric string. Spaces are allowed. + botName: Coinbase Advanced Trade Bot + + # This must be set to prevent catastrophic loss on the exchange. + # This is normally the currency you intend to hold a long position in. It should be set to the currency short code for the + # wallet, e.g. BTC, LTC, USD. This value can be case-sensitive for some exchanges - check the Exchange Adapter documentation. + emergencyStopCurrency: BTC + + # This must be set to prevent a catastrophic loss on the exchange. + # The Trading Engine checks this value at the start of every trade cycle: if your emergencyStopCurrency balance on + # the trading drops below this value, the Trading Engine will stop trading on all markets and shutdown. + # Manual intervention is then required to restart the bot. You can set this value to 0 to override this check. + emergencyStopBalance: 0.7 + + # This is the interval in seconds that the Trading Engine will wait/sleep before executing + # the next trade cycle. The minimum value is 1 second. Some exchanges allow you to hit them harder than others. However, + # while their API documentation might say one thing, the reality is you might get socket timeouts and 5XX responses if you + # hit it too hard - you cannot perform ultra low latency trading over the public internet ;-) + # You'll need to experiment with the trade cycle interval for different exchanges. + tradeCycleInterval: 60 diff --git a/config/samples/coinbase/exchange.yaml b/config/samples/coinbase/exchange.yaml new file mode 100644 index 000000000..5e46d1024 --- /dev/null +++ b/config/samples/coinbase/exchange.yaml @@ -0,0 +1,65 @@ +############################################################################################ +# Exchange Adapter YAML config. +# +# - Sample config below currently set to run against Coinbase Advanced Trade. +# - All fields are mandatory unless stated otherwise. +# - BX-bot only supports running 1 exchange per bot. +# - The indentation levels are significant in YAML: https://en.wikipedia.org/wiki/YAML +# +# See the README "How do I write my own Exchange Adapter?" section for more details. +############################################################################################ +--- +exchange: + + # A friendly name for the Exchange. Value must be an alphanumeric string. Spaces are allowed. + name: Coinbase Advanced Trade + + # For the adapter value, you must specify the fully qualified name of your Exchange Adapter class so the Trading Engine + # can load and execute it. The class must be on the runtime classpath. + adapter: com.gazbert.bxbot.exchanges.CoinbaseAdvancedTradeExchangeAdapter + + authenticationConfig: + # See: TODO: https://docs.pro.coinbase.com/#authentication to get your Coinbase Advanced Trade API credentials. + passphrase: your-passphrase + key: your-api-key + secret: your-secret-key + + networkConfig: + # This value is in SECONDS. It is the timeout value that the exchange adapter will wait on socket connect/socket read + # when communicating with the exchange. Once this threshold has been breached, the exchange adapter will give up and + # throw a Trading API TimeoutException. + # + # The exchange adapter is single threaded: if one request gets blocked, it will block all subsequent requests from + # getting to the exchange. This timeout prevents an indefinite block. + # + # You'll need to experiment with values here. + connectionTimeout: 30 + + # Optional HTTP status codes that will trigger the adapter to throw a non-fatal ExchangeNetworkException + # if the exchange returns any of the below in an API call response: + nonFatalErrorCodes: [502, 503, 504, 520, 522, 525] + + # Optional java.io exception messages that will trigger the adapter to throw a non-fatal ExchangeNetworkException + # if the exchange returns any of the below in an API call response: + nonFatalErrorMessages: + - Connection reset + - Connection refused + - Remote host closed connection during handshake + - Unexpected end of file from server + + otherConfig: + # Exchange Taker Buy fee in % + # IMPORTANT - keep an eye on the fees: + # TODO: https://help.coinbase.com/en/pro/trading-and-funding/trading-rules-and-fees/fees.html + buy-fee: 0.5 + + # Exchange Taker Sell fee in % + # IMPORTANT - keep an eye on the fees: + # TODO: https://help.coinbase.com/en/pro/trading-and-funding/trading-rules-and-fees/fees.html + sell-fee: 0.5 + + # Amount of time in seconds to add to the locally calculated timestamp used to sign the message + # sent to the exchange. This allows for slight skew between the bot's local time and that + # of the exchange. See: TODO: https://docs.pro.coinbase.com/#selecting-a-timestamp + # Start with 0 and see how you get on... + time-server-bias: 0 diff --git a/config/samples/coinbase/markets.yaml b/config/samples/coinbase/markets.yaml new file mode 100644 index 000000000..5802c3de7 --- /dev/null +++ b/config/samples/coinbase/markets.yaml @@ -0,0 +1,33 @@ +############################################################################################ +# Market YAML config. +# +# - All fields are mandatory unless stated otherwise. +# - Multiple market blocks can be listed. +# - The indentation levels are significant in YAML: https://en.wikipedia.org/wiki/YAML +############################################################################################ +--- +markets: + + # The id value is the market id as defined on the exchange, e.g. 'BTC-GBP'. + - id: BTC-GBP + + # A friendly name for the market. + # Value must be an alphanumeric string. Spaces are allowed. E.g. BTC/GBP + name: BTC/GBP + + # The baseCurrency value is the currency short code for the base currency in the currency pair. When you buy or sell a + # currency pair, you are performing that action on the base currency. The base currency is the commodity you are buying or + # selling. E.g. in a BTC/GBP market, the first currency (BTC) is the base currency and the second currency (GBP) is the + # counter currency. + baseCurrency: BTC + + # The counterCurrency value is the currency short code for the counter currency in the currency pair. This is also known + # as the quote currency. + counterCurrency: GBP + + # The enabled value allows you toggle trading on the market - config changes are only applied on startup. + enabled: true + + # The tradingStrategyId value must match a strategy id defined in your strategies.yaml config. + # Currently, BX-bot only supports 1 strategy per market. + tradingStrategyId: scalping-strategy diff --git a/config/samples/coinbase/strategies.yaml b/config/samples/coinbase/strategies.yaml new file mode 100644 index 000000000..aa953259d --- /dev/null +++ b/config/samples/coinbase/strategies.yaml @@ -0,0 +1,45 @@ +############################################################################################ +# Trading Strategy YAML config. +# +# - You configure the loading of your strategy using either a className or a beanName field. +# - All fields are mandatory unless stated otherwise. +# - Multiple strategy blocks can be listed. +# - The indentation levels are significant in YAML: https://en.wikipedia.org/wiki/YAML +# +# See the README "How do I write my own Trading Strategy?" section for full details. +############################################################################################ +--- +strategies: + + # A unique identifier for the strategy. The markets.yaml tradingStrategyId entries reference this. + # Value must be an alphanumeric string. Underscores and dashes are also permitted. E.g. my-macd-strat-1 + - id: scalping-strategy + + # A friendly name for the strategy. + # Value must be an alphanumeric string. Spaces are allowed. E.g. My Super MACD Strat + name: Basic Scalping Strat + + # The description value is optional. + description: > + A simple trend following scalper that buys at the current BID price, holds until current market price has reached + a configurable minimum percentage gain, and then sells at current ASK price, thereby taking profit from the spread. + Don't forget to factor in the exchange fees! + + # For the className value, you must specify the fully qualified name of your Strategy class for the + # Trading Engine to load and execute. This class must be on the runtime classpath. + # If you set this value to load your strategy, you cannot set the beanName value. + className: com.gazbert.bxbot.strategies.ExampleScalpingStrategy + + # For the beanName value, you must specify the Spring bean name of you Strategy component class + # for the Trading Engine to load and execute. + # You will also need to annotate your strategy class with `@Component("exampleScalpingStrategy")` - + # take a look at ExampleScalpingStrategy.java. This results in Spring injecting the bean. + # (see https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/stereotype/Component.html) + # If you set this value to load your strategy, you cannot set the className value. + #beanName: exampleScalpingStrategy + + # The configItems section is optional and allows you to set custom key/value pair config items. This config + # is passed to your Trading Strategy when the bot starts up. + configItems: + counter-currency-buy-order-amount: 20 + minimum-percentage-gain: 2 diff --git a/config/samples/gemini/engine.yaml b/config/samples/gemini/engine.yaml index 45a9b9e3f..93c5bddd9 100644 --- a/config/samples/gemini/engine.yaml +++ b/config/samples/gemini/engine.yaml @@ -17,7 +17,7 @@ engine: # This must be set to prevent catastrophic loss on the exchange. # This is normally the currency you intend to hold a long position in. It should be set to the currency short code for the - # wallet, e.g. BTC, LTC, USD. This value can be case sensitive for some exchanges - check the Exchange Adapter documentation. + # wallet, e.g. BTC, LTC, USD. This value can be case-sensitive for some exchanges - check the Exchange Adapter documentation. emergencyStopCurrency: BTC # This must be set to prevent a catastrophic loss on the exchange. @@ -26,7 +26,7 @@ engine: # Manual intervention is then required to restart the bot. You can set this value to 0 to override this check. emergencyStopBalance: 1.7 - # The is the interval in seconds that the Trading Engine will wait/sleep before executing + # This is the interval in seconds that the Trading Engine will wait/sleep before executing # the next trade cycle. The minimum value is 1 second. Some exchanges allow you to hit them harder than others. However, # while their API documentation might say one thing, the reality is you might get socket timeouts and 5XX responses if you # hit it too hard - you cannot perform ultra low latency trading over the public internet ;-) diff --git a/config/samples/kraken/engine.yaml b/config/samples/kraken/engine.yaml index 5c98006cb..1e96bbc26 100644 --- a/config/samples/kraken/engine.yaml +++ b/config/samples/kraken/engine.yaml @@ -40,4 +40,4 @@ engine: # while their API documentation might say one thing, the reality is you might get socket timeouts and 5XX responses if you # hit it too hard - you cannot perform ultra low latency trading over the public internet ;-) # You'll need to experiment with the trade cycle interval for different exchanges. - tradeCycleInterval: 60 \ No newline at end of file + tradeCycleInterval: 60 diff --git a/etc/checkstyle-suppressions.xml b/etc/checkstyle-suppressions.xml index 9fda184f9..d00d1ad2f 100644 --- a/etc/checkstyle-suppressions.xml +++ b/etc/checkstyle-suppressions.xml @@ -20,4 +20,7 @@ + \ No newline at end of file diff --git a/etc/spotbugs-exclude-filter.xml b/etc/spotbugs-exclude-filter.xml index 79755c6b5..3bf6fb84f 100644 --- a/etc/spotbugs-exclude-filter.xml +++ b/etc/spotbugs-exclude-filter.xml @@ -46,6 +46,12 @@ + + + + + + @@ -70,6 +76,14 @@ + + + + + +