diff --git a/pom.xml b/pom.xml index 55a645dd44a..0978be22dd3 100644 --- a/pom.xml +++ b/pom.xml @@ -42,7 +42,7 @@ 1.2.3 0.9.11 1.8.0-beta4 - 4.1.1 + 4.2.0 3.8.0 true @@ -180,6 +180,7 @@ xchange-quadrigacx xchange-quoine xchange-ripple + xchange-simulated xchange-therock xchange-truefx xchange-upbit diff --git a/xchange-binance/src/main/java/org/knowm/xchange/binance/BinanceErrorAdapter.java b/xchange-binance/src/main/java/org/knowm/xchange/binance/BinanceErrorAdapter.java index d30b8175698..b8a9ee2bd4a 100644 --- a/xchange-binance/src/main/java/org/knowm/xchange/binance/BinanceErrorAdapter.java +++ b/xchange-binance/src/main/java/org/knowm/xchange/binance/BinanceErrorAdapter.java @@ -6,6 +6,7 @@ import org.knowm.xchange.exceptions.ExchangeException; import org.knowm.xchange.exceptions.ExchangeSecurityException; import org.knowm.xchange.exceptions.ExchangeUnavailableException; +import org.knowm.xchange.exceptions.FundsExceededException; import org.knowm.xchange.exceptions.NonceException; import org.knowm.xchange.exceptions.RateLimitExceededException; @@ -24,6 +25,14 @@ public static ExchangeException adapt(BinanceException e) { return new ExchangeSecurityException(message, e); case -1003: return new RateLimitExceededException(message, e); + case -1010: + case -2010: + case -2011: + if (e.getMessage().contains("insufficient balance")) { + return new FundsExceededException(e.getMessage(), e); + } else { + return new ExchangeException(message, e); + } case -1016: return new ExchangeUnavailableException(message, e); case -1021: diff --git a/xchange-bitmex/src/main/java/org/knowm/xchange/bitmex/BitmexAdapters.java b/xchange-bitmex/src/main/java/org/knowm/xchange/bitmex/BitmexAdapters.java index b6dedbe664a..9c27cfe8c4a 100644 --- a/xchange-bitmex/src/main/java/org/knowm/xchange/bitmex/BitmexAdapters.java +++ b/xchange-bitmex/src/main/java/org/knowm/xchange/bitmex/BitmexAdapters.java @@ -207,8 +207,7 @@ public static LimitOrder adaptLimitOrder(BitmexOrder bitmexOrder, String id) { } public static OrderType adaptOrderType(BitmexSide bitmexType) { - - return bitmexType.equals(BitmexSide.BUY) ? OrderType.BID : OrderType.ASK; + return bitmexType == null ? null : bitmexType.equals(BitmexSide.BUY) ? OrderType.BID : OrderType.ASK; } public static String adaptOrderId(BitmexOrderResponse orderResponse) { diff --git a/xchange-examples/src/main/java/org/knowm/xchange/examples/kucoin/account/KucoinAccountDemo.java b/xchange-examples/src/main/java/org/knowm/xchange/examples/kucoin/account/KucoinAccountDemo.java index 085e9cc35a0..007127c43da 100644 --- a/xchange-examples/src/main/java/org/knowm/xchange/examples/kucoin/account/KucoinAccountDemo.java +++ b/xchange-examples/src/main/java/org/knowm/xchange/examples/kucoin/account/KucoinAccountDemo.java @@ -1,12 +1,13 @@ package org.knowm.xchange.examples.kucoin.account; -import com.kucoin.sdk.rest.response.AccountBalancesResponse; import java.io.IOException; import java.util.List; + import org.knowm.xchange.Exchange; import org.knowm.xchange.dto.account.AccountInfo; import org.knowm.xchange.examples.kucoin.KucoinExamplesUtils; import org.knowm.xchange.kucoin.KucoinAccountServiceRaw; +import org.knowm.xchange.kucoin.dto.response.AccountBalancesResponse; import org.knowm.xchange.service.account.AccountService; public class KucoinAccountDemo { diff --git a/xchange-examples/src/main/java/org/knowm/xchange/examples/kucoin/marketdata/KucoinMarketDataDemo.java b/xchange-examples/src/main/java/org/knowm/xchange/examples/kucoin/marketdata/KucoinMarketDataDemo.java index 986e380a276..9bb0c5401c7 100644 --- a/xchange-examples/src/main/java/org/knowm/xchange/examples/kucoin/marketdata/KucoinMarketDataDemo.java +++ b/xchange-examples/src/main/java/org/knowm/xchange/examples/kucoin/marketdata/KucoinMarketDataDemo.java @@ -1,10 +1,5 @@ package org.knowm.xchange.examples.kucoin.marketdata; -import com.kucoin.sdk.rest.response.OrderBookResponse; -import com.kucoin.sdk.rest.response.SymbolResponse; -import com.kucoin.sdk.rest.response.SymbolTickResponse; -import com.kucoin.sdk.rest.response.TickerResponse; -import com.kucoin.sdk.rest.response.TradeHistoryResponse; import java.io.IOException; import java.util.Arrays; import java.util.List; @@ -16,6 +11,11 @@ import org.knowm.xchange.dto.marketdata.Trades; import org.knowm.xchange.kucoin.KucoinExchange; import org.knowm.xchange.kucoin.KucoinMarketDataService; +import org.knowm.xchange.kucoin.dto.response.OrderBookResponse; +import org.knowm.xchange.kucoin.dto.response.SymbolResponse; +import org.knowm.xchange.kucoin.dto.response.SymbolTickResponse; +import org.knowm.xchange.kucoin.dto.response.TickerResponse; +import org.knowm.xchange.kucoin.dto.response.TradeHistoryResponse; import org.knowm.xchange.service.marketdata.MarketDataService; public class KucoinMarketDataDemo { diff --git a/xchange-examples/src/main/java/org/knowm/xchange/examples/kucoin/trade/KucoinTradeDemo.java b/xchange-examples/src/main/java/org/knowm/xchange/examples/kucoin/trade/KucoinTradeDemo.java index 047b026df80..a317e01a7b7 100644 --- a/xchange-examples/src/main/java/org/knowm/xchange/examples/kucoin/trade/KucoinTradeDemo.java +++ b/xchange-examples/src/main/java/org/knowm/xchange/examples/kucoin/trade/KucoinTradeDemo.java @@ -1,9 +1,5 @@ package org.knowm.xchange.examples.kucoin.trade; -import com.kucoin.sdk.exception.KucoinApiException; -import com.kucoin.sdk.rest.request.OrderCreateApiRequest; -import com.kucoin.sdk.rest.response.OrderCancelResponse; -import com.kucoin.sdk.rest.response.OrderCreateResponse; import java.math.BigDecimal; import java.util.Optional; import java.util.UUID; @@ -15,7 +11,11 @@ import org.knowm.xchange.dto.trade.OpenOrders; import org.knowm.xchange.dto.trade.StopOrder; import org.knowm.xchange.examples.kucoin.KucoinExamplesUtils; +import org.knowm.xchange.exceptions.ExchangeException; import org.knowm.xchange.kucoin.KucoinTradeServiceRaw; +import org.knowm.xchange.kucoin.dto.request.OrderCreateApiRequest; +import org.knowm.xchange.kucoin.dto.response.OrderCancelResponse; +import org.knowm.xchange.kucoin.dto.response.OrderCreateResponse; import org.knowm.xchange.service.trade.TradeService; import org.knowm.xchange.service.trade.params.TradeHistoryParamCurrencyPair; import org.knowm.xchange.service.trade.params.orders.OpenOrdersParamCurrencyPair; @@ -96,7 +96,7 @@ private static void genericLimitOrder(TradeService tradeService) throws Exceptio if (tradeService.cancelOrder("NONEXISTENT")) { throw new AssertionError("Shouldn't be able to cancel a non-existent order"); } - } catch (KucoinApiException e) { + } catch (ExchangeException e) { // Fine } diff --git a/xchange-examples/src/main/java/org/knowm/xchange/examples/simulated/SimulatedExchangeDemo.java b/xchange-examples/src/main/java/org/knowm/xchange/examples/simulated/SimulatedExchangeDemo.java new file mode 100644 index 00000000000..b027c1c4564 --- /dev/null +++ b/xchange-examples/src/main/java/org/knowm/xchange/examples/simulated/SimulatedExchangeDemo.java @@ -0,0 +1,13 @@ +package org.knowm.xchange.examples.simulated; + +import java.io.IOException; + +public class SimulatedExchangeDemo { + + public static void main(String[] args) throws IOException { + + System.out.println("See SimulatedExchangeExample in the xchange-simulated module's test tree."); + + } + +} \ No newline at end of file diff --git a/xchange-kraken/src/main/resources/kraken.json b/xchange-kraken/src/main/resources/kraken.json index a98d2fb5b42..6c263ecafb2 100644 --- a/xchange-kraken/src/main/resources/kraken.json +++ b/xchange-kraken/src/main/resources/kraken.json @@ -123,16 +123,6 @@ "min_amount": 0.030, "trading_fee": 0.0026 }, - "ICN/BTC": { - "price_scale": 6, - "min_amount": 2.000, - "trading_fee": 0.0026 - }, - "ICN/ETH": { - "price_scale": 6, - "min_amount": 2.000, - "trading_fee": 0.0026 - }, "LTC/EUR": { "price_scale": 2, "min_amount": 0.100, diff --git a/xchange-kucoin/pom.xml b/xchange-kucoin/pom.xml index f7e6c648cb0..7811ea2d068 100644 --- a/xchange-kucoin/pom.xml +++ b/xchange-kucoin/pom.xml @@ -28,10 +28,13 @@ xchange-core ${project.version} - - com.gruelbox - kucoin-java-sdk - 2.0.0 - + + org.projectlombok + lombok + + + com.google.guava + guava + diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinAccountService.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinAccountService.java index e380642932f..08fa63a8ab6 100644 --- a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinAccountService.java +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinAccountService.java @@ -2,11 +2,11 @@ import static java.util.stream.Collectors.toList; -import com.kucoin.sdk.rest.response.AccountBalancesResponse; import java.io.IOException; import java.util.List; import org.knowm.xchange.dto.account.AccountInfo; import org.knowm.xchange.dto.account.Wallet; +import org.knowm.xchange.kucoin.dto.response.AccountBalancesResponse; import org.knowm.xchange.service.account.AccountService; public class KucoinAccountService extends KucoinAccountServiceRaw implements AccountService { diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinAccountServiceRaw.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinAccountServiceRaw.java index c1e4decc1e9..9ff5d01b495 100644 --- a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinAccountServiceRaw.java +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinAccountServiceRaw.java @@ -2,9 +2,9 @@ import static org.knowm.xchange.kucoin.KucoinExceptionClassifier.classifyingExceptions; -import com.kucoin.sdk.rest.response.AccountBalancesResponse; import java.io.IOException; import java.util.List; +import org.knowm.xchange.kucoin.dto.response.AccountBalancesResponse; public class KucoinAccountServiceRaw extends KucoinBaseService { @@ -13,6 +13,8 @@ protected KucoinAccountServiceRaw(KucoinExchange exchange) { } public List getKucoinAccounts() throws IOException { - return classifyingExceptions(() -> kucoinRestClient.accountAPI().listAccounts(null, null)); + checkAuthenticated(); + return classifyingExceptions( + () -> accountApi.getAccountList(apiKey, digest, nonceFactory, passphrase, null, null)); } } diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinAdapters.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinAdapters.java index 3a57bd728de..96a4d4a254c 100644 --- a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinAdapters.java +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinAdapters.java @@ -10,14 +10,6 @@ import com.google.common.base.MoreObjects; import com.google.common.collect.Ordering; -import com.kucoin.sdk.rest.request.OrderCreateApiRequest; -import com.kucoin.sdk.rest.response.AccountBalancesResponse; -import com.kucoin.sdk.rest.response.OrderBookResponse; -import com.kucoin.sdk.rest.response.OrderResponse; -import com.kucoin.sdk.rest.response.SymbolResponse; -import com.kucoin.sdk.rest.response.SymbolTickResponse; -import com.kucoin.sdk.rest.response.TradeHistoryResponse; -import com.kucoin.sdk.rest.response.TradeResponse; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Date; @@ -48,6 +40,14 @@ import org.knowm.xchange.dto.trade.UserTrade; import org.knowm.xchange.exceptions.ExchangeException; import org.knowm.xchange.kucoin.KucoinTradeService.KucoinOrderFlags; +import org.knowm.xchange.kucoin.dto.request.OrderCreateApiRequest; +import org.knowm.xchange.kucoin.dto.response.AccountBalancesResponse; +import org.knowm.xchange.kucoin.dto.response.OrderBookResponse; +import org.knowm.xchange.kucoin.dto.response.OrderResponse; +import org.knowm.xchange.kucoin.dto.response.SymbolResponse; +import org.knowm.xchange.kucoin.dto.response.SymbolTickResponse; +import org.knowm.xchange.kucoin.dto.response.TradeHistoryResponse; +import org.knowm.xchange.kucoin.dto.response.TradeResponse; public class KucoinAdapters { @@ -244,11 +244,12 @@ public static UserTrade adaptUserTrade(TradeResponse trade) { } public static OrderCreateApiRequest adaptLimitOrder(LimitOrder limitOrder) { - return adaptOrder(limitOrder).type("limit").price(limitOrder.getLimitPrice()).build(); + return ((OrderCreateApiRequest.OrderCreateApiRequestBuilder)adaptOrder(limitOrder)) + .type("limit").price(limitOrder.getLimitPrice()).build(); } public static OrderCreateApiRequest adaptStopOrder(StopOrder stopOrder) { - return adaptOrder(stopOrder) + return ((OrderCreateApiRequest.OrderCreateApiRequestBuilder)adaptOrder(stopOrder)) .type(stopOrder.getLimitPrice() == null ? "market" : "limit") .price(stopOrder.getLimitPrice()) .stop(stopOrder.getType().equals(ASK) ? "loss" : "entry") @@ -257,10 +258,15 @@ public static OrderCreateApiRequest adaptStopOrder(StopOrder stopOrder) { } public static OrderCreateApiRequest adaptMarketOrder(MarketOrder marketOrder) { - return adaptOrder(marketOrder).type("market").build(); + return ((OrderCreateApiRequest.OrderCreateApiRequestBuilder)adaptOrder(marketOrder)) + .type("market").build(); } - public static OrderCreateApiRequest.OrderCreateApiRequestBuilder adaptOrder(Order order) { + /** + * Returns {@code Object} instead of the Lombok builder in order to avoid + * a Lombok limitation with Javadoc. + */ + private static Object adaptOrder(Order order) { OrderCreateApiRequest.OrderCreateApiRequestBuilder request = OrderCreateApiRequest.builder(); boolean hasClientId = false; for (IOrderFlags flag : order.getOrderFlags()) { diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinBaseService.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinBaseService.java index 92e225bc38b..efee45eb5de 100644 --- a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinBaseService.java +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinBaseService.java @@ -1,27 +1,58 @@ package org.knowm.xchange.kucoin; -import com.kucoin.sdk.KucoinClientBuilder; -import com.kucoin.sdk.KucoinRestClient; -import org.apache.commons.lang3.StringUtils; -import org.knowm.xchange.ExchangeSpecification; +import com.google.common.base.Strings; +import org.knowm.xchange.kucoin.service.AccountAPI; +import org.knowm.xchange.kucoin.service.FillAPI; +import org.knowm.xchange.kucoin.service.HistoryAPI; +import org.knowm.xchange.kucoin.service.KucoinApiException; +import org.knowm.xchange.kucoin.service.KucoinDigest; +import org.knowm.xchange.kucoin.service.OrderAPI; +import org.knowm.xchange.kucoin.service.OrderBookAPI; +import org.knowm.xchange.kucoin.service.SymbolAPI; import org.knowm.xchange.service.BaseExchangeService; import org.knowm.xchange.service.BaseService; +import si.mazi.rescu.RestProxyFactory; +import si.mazi.rescu.SynchronizedValueFactory; public class KucoinBaseService extends BaseExchangeService implements BaseService { - protected final KucoinRestClient kucoinRestClient; + protected final SymbolAPI symbolApi; + protected final OrderBookAPI orderBookApi; + protected final HistoryAPI historyApi; + protected final AccountAPI accountApi; + protected final OrderAPI orderApi; + protected final FillAPI fillApi; + + protected KucoinDigest digest; + protected String apiKey; + protected String passphrase; + protected SynchronizedValueFactory nonceFactory; protected KucoinBaseService(KucoinExchange exchange) { super(exchange); - ExchangeSpecification spec = exchange.getExchangeSpecification(); - KucoinClientBuilder builder = new KucoinClientBuilder().withBaseUrl(spec.getSslUri()); - if (StringUtils.isNotEmpty(spec.getApiKey())) { - builder.withApiKey( - spec.getApiKey(), - spec.getSecretKey(), - (String) - exchange.getExchangeSpecification().getExchangeSpecificParametersItem("passphrase")); - } - kucoinRestClient = builder.buildRestClient(); + this.symbolApi = service(exchange, SymbolAPI.class); + this.orderBookApi = service(exchange, OrderBookAPI.class); + this.historyApi = service(exchange, HistoryAPI.class); + this.accountApi = service(exchange, AccountAPI.class); + this.orderApi = service(exchange, OrderAPI.class); + this.fillApi = service(exchange, FillAPI.class); + + this.digest = KucoinDigest.createInstance(exchange.getExchangeSpecification().getSecretKey()); + this.apiKey = exchange.getExchangeSpecification().getApiKey(); + this.passphrase = + (String) + exchange.getExchangeSpecification().getExchangeSpecificParametersItem("passphrase"); + this.nonceFactory = exchange.getNonceFactory(); + } + + private T service(KucoinExchange exchange, Class clazz) { + return RestProxyFactory.createProxy( + clazz, exchange.getExchangeSpecification().getSslUri(), getClientConfig()); + } + + protected void checkAuthenticated() { + if (Strings.isNullOrEmpty(this.apiKey)) throw new KucoinApiException("Missing API key"); + if (this.digest == null) throw new KucoinApiException("Missing secret key"); + if (Strings.isNullOrEmpty(this.passphrase)) throw new KucoinApiException("Missing passphrase"); } } diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinExceptionClassifier.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinExceptionClassifier.java index 2b6a1cff6a0..23c13030e48 100644 --- a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinExceptionClassifier.java +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinExceptionClassifier.java @@ -1,26 +1,24 @@ package org.knowm.xchange.kucoin; -import com.kucoin.sdk.exception.KucoinApiException; import java.io.IOException; import org.knowm.xchange.exceptions.ExchangeException; import org.knowm.xchange.exceptions.ExchangeSecurityException; import org.knowm.xchange.exceptions.ExchangeUnavailableException; import org.knowm.xchange.exceptions.NonceException; +import org.knowm.xchange.kucoin.dto.response.KucoinResponse; +import org.knowm.xchange.kucoin.service.KucoinApiException; public final class KucoinExceptionClassifier { KucoinExceptionClassifier() {} - public static T classifyingExceptions(IOExceptionThrowingSupplier apiCall) + public static T classifyingExceptions(IOExceptionThrowingSupplier> apiCall) throws IOException { - try { - T result = apiCall.get(); - if (result == null) { - throw new ExchangeException("Empty response from Kucoin. Check logs."); - } - return result; - } catch (KucoinApiException e) { - throw classify(e); + KucoinResponse response = apiCall.get(); + if (response.isSuccessful()) { + return response.getData(); + } else { + throw classify(new KucoinApiException(response.getCode(), response.getMessage())); } } diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinExchange.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinExchange.java index 0354834db75..2bbce73b028 100644 --- a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinExchange.java +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinExchange.java @@ -5,10 +5,13 @@ import org.knowm.xchange.Exchange; import org.knowm.xchange.ExchangeSpecification; import org.knowm.xchange.exceptions.ExchangeException; +import org.knowm.xchange.utils.nonce.CurrentTimeNonceFactory; import si.mazi.rescu.SynchronizedValueFactory; public class KucoinExchange extends BaseExchange implements Exchange { + private final SynchronizedValueFactory nonceFactory = new CurrentTimeNonceFactory(); + /** * Use with {@link ExchangeSpecification#getExchangeSpecificParametersItem(String)} to specify * that connection should be made to the Kucoin sandbox instead of the live API. @@ -62,7 +65,7 @@ public ExchangeSpecification getDefaultExchangeSpecification() { @Override public SynchronizedValueFactory getNonceFactory() { - throw new UnsupportedOperationException("Kucoin does not use a nonce factory."); + return nonceFactory; } @Override diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinMarketDataServiceRaw.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinMarketDataServiceRaw.java index 596724c37ee..21ef474b4d6 100644 --- a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinMarketDataServiceRaw.java +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinMarketDataServiceRaw.java @@ -2,14 +2,14 @@ import static org.knowm.xchange.kucoin.KucoinExceptionClassifier.classifyingExceptions; -import com.kucoin.sdk.rest.response.OrderBookResponse; -import com.kucoin.sdk.rest.response.SymbolResponse; -import com.kucoin.sdk.rest.response.SymbolTickResponse; -import com.kucoin.sdk.rest.response.TickerResponse; -import com.kucoin.sdk.rest.response.TradeHistoryResponse; import java.io.IOException; import java.util.List; import org.knowm.xchange.currency.CurrencyPair; +import org.knowm.xchange.kucoin.dto.response.OrderBookResponse; +import org.knowm.xchange.kucoin.dto.response.SymbolResponse; +import org.knowm.xchange.kucoin.dto.response.SymbolTickResponse; +import org.knowm.xchange.kucoin.dto.response.TickerResponse; +import org.knowm.xchange.kucoin.dto.response.TradeHistoryResponse; public class KucoinMarketDataServiceRaw extends KucoinBaseService { @@ -18,40 +18,30 @@ protected KucoinMarketDataServiceRaw(KucoinExchange exchange) { } public TickerResponse getKucoinTicker(CurrencyPair pair) throws IOException { - return classifyingExceptions( - () -> kucoinRestClient.symbolAPI().getTicker(KucoinAdapters.adaptCurrencyPair(pair))); + return classifyingExceptions(() -> symbolApi.getTicker(KucoinAdapters.adaptCurrencyPair(pair))); } public SymbolTickResponse getKucoin24hrStats(CurrencyPair pair) throws IOException { return classifyingExceptions( - () -> kucoinRestClient.symbolAPI().get24hrStats(KucoinAdapters.adaptCurrencyPair(pair))); + () -> symbolApi.getMarketStats(KucoinAdapters.adaptCurrencyPair(pair))); } public List getKucoinSymbols() throws IOException { - return classifyingExceptions(() -> kucoinRestClient.symbolAPI().getSymbols()); + return classifyingExceptions(() -> symbolApi.getSymbols()); } public OrderBookResponse getKucoinOrderBookPartial(CurrencyPair pair) throws IOException { return classifyingExceptions( - () -> - kucoinRestClient - .orderBookAPI() - .getPartOrderBookAggregated(KucoinAdapters.adaptCurrencyPair(pair))); + () -> orderBookApi.getPartOrderBookAggregated(KucoinAdapters.adaptCurrencyPair(pair))); } public OrderBookResponse getKucoinOrderBookFull(CurrencyPair pair) throws IOException { return classifyingExceptions( - () -> - kucoinRestClient - .orderBookAPI() - .getFullOrderBookAggregated(KucoinAdapters.adaptCurrencyPair(pair))); + () -> orderBookApi.getFullOrderBookAggregated(KucoinAdapters.adaptCurrencyPair(pair))); } public List getKucoinTrades(CurrencyPair pair) throws IOException { return classifyingExceptions( - () -> - kucoinRestClient - .historyAPI() - .getTradeHistories(KucoinAdapters.adaptCurrencyPair(pair))); + () -> historyApi.getTradeHistories(KucoinAdapters.adaptCurrencyPair(pair))); } } diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinTradeService.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinTradeService.java index fae7e2c39c9..9c27478f94a 100644 --- a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinTradeService.java +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinTradeService.java @@ -5,9 +5,6 @@ import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList.Builder; -import com.kucoin.sdk.rest.response.OrderCancelResponse; -import com.kucoin.sdk.rest.response.OrderResponse; -import com.kucoin.sdk.rest.response.TradeResponse; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; @@ -20,6 +17,9 @@ import org.knowm.xchange.dto.trade.OpenOrders; import org.knowm.xchange.dto.trade.StopOrder; import org.knowm.xchange.dto.trade.UserTrades; +import org.knowm.xchange.kucoin.dto.response.OrderCancelResponse; +import org.knowm.xchange.kucoin.dto.response.OrderResponse; +import org.knowm.xchange.kucoin.dto.response.TradeResponse; import org.knowm.xchange.service.trade.TradeService; import org.knowm.xchange.service.trade.params.CancelOrderByIdParams; import org.knowm.xchange.service.trade.params.CancelOrderParams; diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinTradeServiceRaw.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinTradeServiceRaw.java index 4daf6659ce7..61e29ca1481 100644 --- a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinTradeServiceRaw.java +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/KucoinTradeServiceRaw.java @@ -2,13 +2,13 @@ import static org.knowm.xchange.kucoin.KucoinExceptionClassifier.classifyingExceptions; -import com.kucoin.sdk.rest.request.OrderCreateApiRequest; -import com.kucoin.sdk.rest.response.OrderCancelResponse; -import com.kucoin.sdk.rest.response.OrderCreateResponse; -import com.kucoin.sdk.rest.response.OrderResponse; -import com.kucoin.sdk.rest.response.Pagination; -import com.kucoin.sdk.rest.response.TradeResponse; import java.io.IOException; +import org.knowm.xchange.kucoin.dto.request.OrderCreateApiRequest; +import org.knowm.xchange.kucoin.dto.response.OrderCancelResponse; +import org.knowm.xchange.kucoin.dto.response.OrderCreateResponse; +import org.knowm.xchange.kucoin.dto.response.OrderResponse; +import org.knowm.xchange.kucoin.dto.response.Pagination; +import org.knowm.xchange.kucoin.dto.response.TradeResponse; public class KucoinTradeServiceRaw extends KucoinBaseService { @@ -18,32 +18,60 @@ protected KucoinTradeServiceRaw(KucoinExchange exchange) { public Pagination getKucoinOpenOrders(String symbol, int page, int pageSize) throws IOException { + checkAuthenticated(); return classifyingExceptions( () -> - kucoinRestClient - .orderAPI() - .listOrders(symbol, null, null, "active", null, null, pageSize, page)); + orderApi.queryOrders( + apiKey, + digest, + nonceFactory, + passphrase, + symbol, + null, + null, + "active", + null, + null, + pageSize, + page)); } public Pagination getKucoinFills(String symbol, int page, int pageSize) throws IOException { + checkAuthenticated(); return classifyingExceptions( () -> - kucoinRestClient - .fillAPI() - .listFills(symbol, null, null, null, null, null, pageSize, page)); + fillApi.queryTrades( + apiKey, + digest, + nonceFactory, + passphrase, + symbol, + null, + null, + null, + null, + null, + pageSize, + page)); } public OrderCancelResponse kucoinCancelAllOrders(String symbol) throws IOException { - return classifyingExceptions(() -> kucoinRestClient.orderAPI().cancelAllOrders(symbol)); + checkAuthenticated(); + return classifyingExceptions( + () -> orderApi.cancelOrders(apiKey, digest, nonceFactory, passphrase, symbol)); } public OrderCancelResponse kucoinCancelOrder(String orderId) throws IOException { - return classifyingExceptions(() -> kucoinRestClient.orderAPI().cancelOrder(orderId)); + checkAuthenticated(); + return classifyingExceptions( + () -> orderApi.cancelOrder(apiKey, digest, nonceFactory, passphrase, orderId)); } public OrderCreateResponse kucoinCreateOrder(OrderCreateApiRequest opsRequest) throws IOException { - return classifyingExceptions(() -> kucoinRestClient.orderAPI().createOrder(opsRequest)); + checkAuthenticated(); + return classifyingExceptions( + () -> orderApi.createOrder(apiKey, digest, nonceFactory, passphrase, opsRequest)); } } diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/request/OrderCancelAPIRequest.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/request/OrderCancelAPIRequest.java new file mode 100644 index 00000000000..0b040507d36 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/request/OrderCancelAPIRequest.java @@ -0,0 +1,18 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.request; + +import lombok.Data; +import lombok.ToString; + +/** + * 订单操作Facade Request类 + * + * @author 屈亮 + * @since 2018-09-17 + */ +@Data +@ToString +public class OrderCancelAPIRequest { + + private String symbol; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/request/OrderCreateApiRequest.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/request/OrderCreateApiRequest.java new file mode 100644 index 00000000000..ef5f5db18e5 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/request/OrderCreateApiRequest.java @@ -0,0 +1,68 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.request; + +import java.math.BigDecimal; +import lombok.Builder; +import lombok.Getter; + +/** + * 订单创建对象 + * + * @author 屈亮 + * @since 2018-09-17 + */ +@Getter +@Builder +public class OrderCreateApiRequest { + + /** a valid trading symbol code. e.g. ETH-BTC */ + private final String symbol; + + /** [optional] limit or market (default is limit) */ + @Builder.Default private final String type = "limit"; + + /** buy or sell */ + private final String side; + + /** price per base currency */ + private final BigDecimal price; + + /** amount of base currency to buy or sell */ + private final BigDecimal size; + + /** [optional] Desired amount of quote currency to use */ + private final BigDecimal funds; + + /** [optional] self trade protect , CN, CO, CB or DC */ + @Builder.Default private final String stp = ""; + + /** [optional] Either loss or entry. Requires stopPrice to be defined */ + @Builder.Default private final String stop = ""; + + /** [optional] Only if stop is defined. Sets trigger price for stop order */ + private final BigDecimal stopPrice; + + /** [optional] GTC, GTT, IOC, or FOK (default is GTC) */ + @Builder.Default private final String timeInForce = "GTC"; + + /** [optional] * cancel after n seconds */ + private final long cancelAfter; + + /** [optional] ** Post only flag */ + private final boolean postOnly; + + /** [optional] Orders not displayed in order book */ + private final boolean hidden; + + /** [optional] Only visible portion of the order is displayed in the order book */ + private final boolean iceberge; + + /** [optional] The maximum visible size of an iceberg order */ + private final BigDecimal visibleSize; + + /** Unique order id selected by you to identify your order e.g. UUID */ + private final String clientOid; + + /** [optional] remark for the order, length cannot exceed 100 utf8 characters */ + private final String remark; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/request/PageRequest.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/request/PageRequest.java new file mode 100644 index 00000000000..4063555cac9 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/request/PageRequest.java @@ -0,0 +1,13 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.request; + +import lombok.Data; + +/** Created by zicong.lu on 2018/12/14. */ +@Data +public class PageRequest { + + private int currentPage = 1; + + private int pageSize = 10; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/AccountBalancesResponse.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/AccountBalancesResponse.java new file mode 100644 index 00000000000..137939dcc64 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/AccountBalancesResponse.java @@ -0,0 +1,24 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.math.BigDecimal; +import lombok.Data; + +/** Created by tao.mao on 2018/11/15. */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class AccountBalancesResponse { + + private String id; + + private String currency; + + private String type; + + private BigDecimal balance; + + private BigDecimal available; + + private BigDecimal holds; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/KucoinResponse.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/KucoinResponse.java new file mode 100644 index 00000000000..f9f93eca587 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/KucoinResponse.java @@ -0,0 +1,30 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.io.Serializable; +import lombok.Data; + +/** Created by zicong.lu on 2018/12/14. */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class KucoinResponse implements Serializable { + private static final long serialVersionUID = 1L; + private static final String SUCCESS_CODE = "200000"; + private String code; + private String msg; + + private R data; + + public boolean isSuccessful() { + return SUCCESS_CODE.equals(this.code); + } + + public String getCode() { + return code; + } + + public String getMessage() { + return msg; + } +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderBook.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderBook.java new file mode 100644 index 00000000000..b7c325cae0c --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderBook.java @@ -0,0 +1,16 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.response; + +import java.util.List; +import lombok.Data; + +/** Created by chenshiwei on 2019/1/19. */ +@Data +public class OrderBook { + + /** [price, size] for aggregated, [orderId, price, size] for atomic */ + private List> asks; + + /** [price, size] for aggregated, [orderId, price, size] for atomic */ + private List> bids; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderBookResponse.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderBookResponse.java new file mode 100644 index 00000000000..fdc1e70c990 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderBookResponse.java @@ -0,0 +1,17 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** Created by chenshiwei on 2019/1/18. */ +@Data +@EqualsAndHashCode(callSuper = true) +@JsonIgnoreProperties(ignoreUnknown = true) +public class OrderBookResponse extends OrderBook { + + private String sequence; + + private long time; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderCancelResponse.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderCancelResponse.java new file mode 100644 index 00000000000..cec7ba1acd5 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderCancelResponse.java @@ -0,0 +1,16 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.HashSet; +import java.util.Set; +import lombok.Data; +import lombok.ToString; + +@Data +@ToString +@JsonIgnoreProperties(ignoreUnknown = true) +public class OrderCancelResponse { + + private Set cancelledOrderIds = new HashSet<>(); +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderCreateResponse.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderCreateResponse.java new file mode 100644 index 00000000000..919f60ee9fa --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderCreateResponse.java @@ -0,0 +1,12 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class OrderCreateResponse { + + private String orderId; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderResponse.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderResponse.java new file mode 100644 index 00000000000..1ce22b019d8 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/OrderResponse.java @@ -0,0 +1,84 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.math.BigDecimal; +import java.util.Date; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class OrderResponse { + + private String id; + + private String symbol; + + private String opType; + + private String type; + + public String getType() { + return this.type == null ? null : this.type.toLowerCase(); + } + + private String side; + + public String getSide() { + return this.side == null ? null : this.side.toLowerCase(); + } + + private BigDecimal price; + + private BigDecimal size; + + private BigDecimal funds; + + private BigDecimal dealFunds; + + private BigDecimal dealSize; + + private BigDecimal fee; + + private String feeCurrency; + + private String stp; + + private String stop; + + public String getStop() { + return this.stop == null ? null : this.stop.toLowerCase(); + } + + private Boolean stopTriggered; + + private BigDecimal stopPrice; + + private String timeInForce; + + private boolean postOnly; + + private boolean hidden; + + private boolean iceberg; + + private BigDecimal visibleSize; + + private Long cancelAfter; + + private String channel; + + private String clientOid; + + private String remark; + + private String tags; + + @JsonProperty("isActive") + private boolean isActive; + + private boolean cancelExist; + + private Date createdAt; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/Pagination.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/Pagination.java new file mode 100644 index 00000000000..b0c7daa80a0 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/Pagination.java @@ -0,0 +1,28 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Created by zicong.lu on 2018/12/21. */ +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class Pagination { + + private Integer currentPage; + private Integer pageSize; + private Long totalNum; + private Integer totalPage; + private List items; + + public Pagination(Integer currentPage, Integer pageSize, Long totalNum, List items) { + this.currentPage = currentPage; + this.pageSize = pageSize; + this.totalNum = totalNum; + this.items = items; + this.totalPage = (int) ((totalNum + (long) pageSize - 1L) / (long) pageSize); + } +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/SymbolResponse.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/SymbolResponse.java new file mode 100644 index 00000000000..2f554e4bfe7 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/SymbolResponse.java @@ -0,0 +1,38 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.math.BigDecimal; +import lombok.Data; + +/** Created by devin@kucoin.com on 2018-12-27. */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SymbolResponse { + + private String symbol; + + private String name; + + private String market; + + private String baseCurrency; + + private String quoteCurrency; + + private BigDecimal baseMinSize; + + private BigDecimal quoteMinSize; + + private BigDecimal baseMaxSize; + + private BigDecimal quoteMaxSize; + + private BigDecimal baseIncrement; + + private BigDecimal quoteIncrement; + + private BigDecimal priceIncrement; + + private boolean enableTrading; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/SymbolTickResponse.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/SymbolTickResponse.java new file mode 100644 index 00000000000..644d9071e92 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/SymbolTickResponse.java @@ -0,0 +1,41 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.math.BigDecimal; +import lombok.Data; + +/** + * @author yi.yang + * @since 2018/12/26. + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SymbolTickResponse { + + private String symbol; + + private BigDecimal changeRate; + + private BigDecimal changePrice; + + private BigDecimal open; + + private BigDecimal close; + + private BigDecimal high; + + private BigDecimal low; + + private BigDecimal vol; + + private BigDecimal volValue; + + private BigDecimal last; + + private BigDecimal buy; + + private BigDecimal sell; + + private long time; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/TickerResponse.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/TickerResponse.java new file mode 100644 index 00000000000..f77c7b67ace --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/TickerResponse.java @@ -0,0 +1,28 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.math.BigDecimal; +import lombok.Data; + +/** Created by chenshiwei on 2019/1/10. */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class TickerResponse { + + private String sequence; + + private BigDecimal bestAsk; + + private BigDecimal bestBid; + + private BigDecimal size; + + private BigDecimal price; + + private BigDecimal bestAskSize; + + private BigDecimal bestBidSize; + + private long time; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/TradeHistoryResponse.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/TradeHistoryResponse.java new file mode 100644 index 00000000000..f80801b2f5a --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/TradeHistoryResponse.java @@ -0,0 +1,22 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.math.BigDecimal; +import lombok.Data; + +/** Created by chenshiwei on 2019/1/18. */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class TradeHistoryResponse { + + private String sequence; + + private BigDecimal price; + + private BigDecimal size; + + private String side; + + private long time; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/TradeResponse.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/TradeResponse.java new file mode 100644 index 00000000000..277bd26c6c2 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/dto/response/TradeResponse.java @@ -0,0 +1,61 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.dto.response; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.math.BigDecimal; +import java.util.Date; +import lombok.Data; +import lombok.ToString; + +@Data +@ToString +@JsonIgnoreProperties(ignoreUnknown = true) +public class TradeResponse { + + private String symbol; + + private String tradeId; + + private String orderId; + + private String counterOrderId; + + private String side; + + public String getSide() { + return this.side == null ? null : this.side.toLowerCase(); + } + + private String liquidity; + + public String getLiquidity() { + return this.liquidity == null ? null : this.liquidity.toLowerCase(); + } + + private boolean forceTaker; + + private BigDecimal price; + + private BigDecimal size; + + private BigDecimal funds; + + private BigDecimal fee; + + private BigDecimal feeRate; + + private String feeCurrency; + + private String domainId; + + @JsonProperty("type") + private String orderType; + + private String stop; + + @JsonProperty("createdAt") + private Date tradeCreatedAt; + + private String displayType; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/APIConstants.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/APIConstants.java new file mode 100644 index 00000000000..a21d9b9ed7a --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/APIConstants.java @@ -0,0 +1,23 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.service; + +/** Based on code by zicong.lu on 2018/12/14. */ +public class APIConstants { + public static final String API_BASE_URL = "https://openapi-v2.kucoin.com/"; + + public static final String USER_API_KEY = "KC-API-KEY"; + public static final String USER_API_SECRET = "KC-API-SECRET"; + public static final String USER_API_PASSPHRASE = "KC-API-PASSPHRASE"; + + public static final String API_HEADER_KEY = "KC-API-KEY"; + public static final String API_HEADER_SIGN = "KC-API-SIGN"; + public static final String API_HEADER_PASSPHRASE = "KC-API-PASSPHRASE"; + public static final String API_HEADER_TIMESTAMP = "KC-API-TIMESTAMP"; + + public static final String API_TICKER_TOPIC_PREFIX = "/market/ticker:"; + public static final String API_LEVEL2_TOPIC_PREFIX = "/market/level2:"; + public static final String API_MATCH_TOPIC_PREFIX = "/market/match:"; + public static final String API_LEVEL3_TOPIC_PREFIX = "/market/level3:"; + public static final String API_ACTIVATE_TOPIC_PREFIX = "/market/level3:"; + public static final String API_BALANCE_TOPIC_PREFIX = "/account/balance"; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/AccountAPI.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/AccountAPI.java new file mode 100644 index 00000000000..5135fc85fe8 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/AccountAPI.java @@ -0,0 +1,42 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.service; + +import java.io.IOException; +import java.util.List; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import org.knowm.xchange.kucoin.dto.response.AccountBalancesResponse; +import org.knowm.xchange.kucoin.dto.response.KucoinResponse; +import si.mazi.rescu.ParamsDigest; +import si.mazi.rescu.SynchronizedValueFactory; + +@Path("/api/v1/accounts") +@Produces(MediaType.APPLICATION_JSON) +public interface AccountAPI { + + /** + * Get a list of accounts. + * + *

Your accounts are separate from your KuCoin accounts. See the Deposits section for + * documentation on how to deposit funds to begin trading. + * + * @param currency The code of the currency + * @param type Account type:,"main" or "trade" + * @return The accounts. + * @throws IOException on socket errors. + * @throws KucoinApiException when errors are returned from the exchange. + */ + @GET + KucoinResponse> getAccountList( + @HeaderParam(APIConstants.API_HEADER_KEY) String apiKey, + @HeaderParam(APIConstants.API_HEADER_SIGN) ParamsDigest signature, + @HeaderParam(APIConstants.API_HEADER_TIMESTAMP) SynchronizedValueFactory nonce, + @HeaderParam(APIConstants.API_HEADER_PASSPHRASE) String apiPassphrase, + @QueryParam("currency") String currency, + @QueryParam("type") String type) + throws IOException; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/FillAPI.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/FillAPI.java new file mode 100644 index 00000000000..4591fd9f1e0 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/FillAPI.java @@ -0,0 +1,52 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.service; + +import java.io.IOException; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import org.knowm.xchange.kucoin.dto.response.KucoinResponse; +import org.knowm.xchange.kucoin.dto.response.Pagination; +import org.knowm.xchange.kucoin.dto.response.TradeResponse; +import si.mazi.rescu.ParamsDigest; +import si.mazi.rescu.SynchronizedValueFactory; + +/** Based on code by chenshiwei on 2019/1/10. */ +@Path("/api/v1/fills") +@Produces(MediaType.APPLICATION_JSON) +public interface FillAPI { + + /** + * Get a list of recent fills. + * + * @param symbol [optional] Limit list of fills to this orderId + * @param orderId [optional] Limit list of fills to this orderId + * @param side [optional] buy or sell + * @param type [optional] limit, market, limit_stop or market_stop + * @param startAt [optional] Start time. unix timestamp calculated in milliseconds, the creation + * time queried shall posterior to the start time. + * @param endAt [optional] End time. unix timestamp calculated in milliseconds, the creation time + * queried shall prior to the end time. + * @param pageSize The page size. + * @param currentPage The page to select. + * @return Trades. + */ + @GET + KucoinResponse> queryTrades( + @HeaderParam(APIConstants.API_HEADER_KEY) String apiKey, + @HeaderParam(APIConstants.API_HEADER_SIGN) ParamsDigest signature, + @HeaderParam(APIConstants.API_HEADER_TIMESTAMP) SynchronizedValueFactory nonce, + @HeaderParam(APIConstants.API_HEADER_PASSPHRASE) String apiPassphrase, + @QueryParam("symbol") String symbol, + @QueryParam("orderId") String orderId, + @QueryParam("side") String side, + @QueryParam("type") String type, + @QueryParam("startAt") Long startAt, + @QueryParam("endAt") Long endAt, + @QueryParam("pageSize") int pageSize, + @QueryParam("currentPage") int currentPage) + throws IOException; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/HistoryAPI.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/HistoryAPI.java new file mode 100644 index 00000000000..31a150da4b8 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/HistoryAPI.java @@ -0,0 +1,29 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.service; + +import java.io.IOException; +import java.util.List; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import org.knowm.xchange.kucoin.dto.response.KucoinResponse; +import org.knowm.xchange.kucoin.dto.response.TradeHistoryResponse; + +/** Based on code by chenshiwei on 2019/1/22. */ +@Path("/api/v1/market") +@Produces(MediaType.APPLICATION_JSON) +public interface HistoryAPI { + + /** + * List the latest trades for a symbol. + * + * @param symbol The symbol whose trades should be fetched. + * @return The trades for the symbol. + */ + @GET + @Path("/histories") + KucoinResponse> getTradeHistories(@QueryParam("symbol") String symbol) + throws IOException; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/KucoinApiException.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/KucoinApiException.java new file mode 100644 index 00000000000..be1f27c4c2a --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/KucoinApiException.java @@ -0,0 +1,39 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.service; + +/** Based on code by zicong.lu on 2018/12/14. */ +public class KucoinApiException extends RuntimeException { + + private static final long serialVersionUID = 8592824166988095909L; + + private String code; + + public KucoinApiException(String message) { + super(message); + } + + public KucoinApiException(String message, Throwable cause) { + super(message, cause); + } + + public KucoinApiException(String code, String message) { + super(message); + this.code = code; + } + + public String getCode() { + return code; + } + + @Override + public String toString() { + return "KucoinApiException{" + + "code='" + + getCode() + + '\'' + + ", message='" + + getMessage() + + '\'' + + '}'; + } +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/KucoinDigest.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/KucoinDigest.java new file mode 100644 index 00000000000..5c0eac173d6 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/KucoinDigest.java @@ -0,0 +1,55 @@ +package org.knowm.xchange.kucoin.service; + +import com.google.common.base.Strings; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import javax.crypto.Mac; +import javax.ws.rs.HeaderParam; +import org.knowm.xchange.exceptions.ExchangeException; +import org.knowm.xchange.service.BaseParamsDigest; +import si.mazi.rescu.RestInvocation; + +/** Almost identical to Coinbase Pro (even down to the text in the API documentation). */ +public class KucoinDigest extends BaseParamsDigest { + + private String signature = ""; + + private KucoinDigest(byte[] secretKey) { + super(secretKey, HMAC_SHA_256); + } + + public static KucoinDigest createInstance(String secretKey) { + return Strings.isNullOrEmpty(secretKey) + ? null + : new KucoinDigest(secretKey.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public String digestParams(RestInvocation restInvocation) { + + String pathWithQueryString = + restInvocation.getInvocationUrl().replace(restInvocation.getBaseUrl(), ""); + String message = + restInvocation + .getParamValue(HeaderParam.class, APIConstants.API_HEADER_TIMESTAMP) + .toString() + + restInvocation.getHttpMethod() + + pathWithQueryString + + (restInvocation.getRequestBody() != null ? restInvocation.getRequestBody() : ""); + + Mac mac256 = getMac(); + + try { + mac256.update(message.getBytes("UTF-8")); + } catch (Exception e) { + throw new ExchangeException("Digest encoding exception", e); + } + + signature = Base64.getEncoder().encodeToString(mac256.doFinal()); + return signature; + } + + public String getSignature() { + return signature; + } +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/OrderAPI.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/OrderAPI.java new file mode 100644 index 00000000000..3b55f6c8938 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/OrderAPI.java @@ -0,0 +1,133 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.service; + +import java.io.IOException; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.HeaderParam; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import org.knowm.xchange.kucoin.dto.request.OrderCreateApiRequest; +import org.knowm.xchange.kucoin.dto.response.KucoinResponse; +import org.knowm.xchange.kucoin.dto.response.OrderCancelResponse; +import org.knowm.xchange.kucoin.dto.response.OrderCreateResponse; +import org.knowm.xchange.kucoin.dto.response.OrderResponse; +import org.knowm.xchange.kucoin.dto.response.Pagination; +import si.mazi.rescu.ParamsDigest; +import si.mazi.rescu.SynchronizedValueFactory; + +/** Based on code by chenshiwei on 2019/1/10. */ +@Path("/api/v1/orders") +@Produces(MediaType.APPLICATION_JSON) +public interface OrderAPI { + + /** + * Place a new order. + * + *

You can place two types of orders: limit and market. Orders can only be placed if your + * account has sufficient funds. Once an order is placed, your account funds will be put on hold + * for the duration of the order. How much and which funds are put on hold depends on the order + * type and parameters specified. + * + *

The maximum matching orders for a single trading pair in one account is 50 (stop limit order + * included). + * + * @param opsRequest Order creation request. + * @return A response containing the order id. + */ + @POST + @Consumes(MediaType.APPLICATION_JSON) + KucoinResponse createOrder( + @HeaderParam(APIConstants.API_HEADER_KEY) String apiKey, + @HeaderParam(APIConstants.API_HEADER_SIGN) ParamsDigest signature, + @HeaderParam(APIConstants.API_HEADER_TIMESTAMP) SynchronizedValueFactory nonce, + @HeaderParam(APIConstants.API_HEADER_PASSPHRASE) String apiPassphrase, + OrderCreateApiRequest opsRequest) + throws IOException; + + /** + * Cancel an order + * + *

Cancel a previously placed order. + * + * @param orderId The order id. + * @return A response containing the id of the cancelled order. + */ + @DELETE + @Path("/{orderId}") + KucoinResponse cancelOrder( + @HeaderParam(APIConstants.API_HEADER_KEY) String apiKey, + @HeaderParam(APIConstants.API_HEADER_SIGN) ParamsDigest signature, + @HeaderParam(APIConstants.API_HEADER_TIMESTAMP) SynchronizedValueFactory nonce, + @HeaderParam(APIConstants.API_HEADER_PASSPHRASE) String apiPassphrase, + @PathParam("orderId") String orderId) + throws IOException; + + /** + * With best effort, cancel all open orders. The response is a list of ids of the canceled orders. + * + * @param symbol The symbol whose orders should be cancelled. + * @return A response containing the ids of all open orders. + */ + @DELETE + KucoinResponse cancelOrders( + @HeaderParam(APIConstants.API_HEADER_KEY) String apiKey, + @HeaderParam(APIConstants.API_HEADER_SIGN) ParamsDigest signature, + @HeaderParam(APIConstants.API_HEADER_TIMESTAMP) SynchronizedValueFactory nonce, + @HeaderParam(APIConstants.API_HEADER_PASSPHRASE) String apiPassphrase, + @QueryParam("symbol") String symbol) + throws IOException; + + /** + * Get a single order by order id. + * + * @param orderId The order id. + * @return The requested order. + */ + @GET + @Path("/{orderId}") + KucoinResponse getOrder( + @HeaderParam(APIConstants.API_HEADER_KEY) String apiKey, + @HeaderParam(APIConstants.API_HEADER_SIGN) ParamsDigest signature, + @HeaderParam(APIConstants.API_HEADER_TIMESTAMP) SynchronizedValueFactory nonce, + @HeaderParam(APIConstants.API_HEADER_PASSPHRASE) String apiPassphrase, + @PathParam("orderId") String orderId) + throws IOException; + + /** + * List your current orders. + * + * @param symbol [optional] Only list orders for a specific symbol. + * @param side [optional] buy or sell + * @param type [optional] limit, market, limit_stop or market_stop + * @param status [optional] active or done, done as default, Only list orders for a specific + * status . + * @param startAt [optional] Start time. unix timestamp calculated in milliseconds, the creation + * time queried shall posterior to the start time. + * @param endAt [optional] End time. unix timestamp calculated in milliseconds, the creation time + * queried shall prior to the end time. + * @param pageSize The page size. + * @param currentPage The page to select. + * @return A page of orders. + */ + @GET + KucoinResponse> queryOrders( + @HeaderParam(APIConstants.API_HEADER_KEY) String apiKey, + @HeaderParam(APIConstants.API_HEADER_SIGN) ParamsDigest signature, + @HeaderParam(APIConstants.API_HEADER_TIMESTAMP) SynchronizedValueFactory nonce, + @HeaderParam(APIConstants.API_HEADER_PASSPHRASE) String apiPassphrase, + @QueryParam("symbol") String symbol, + @QueryParam("side") String side, + @QueryParam("type") String type, + @QueryParam("status") String status, + @QueryParam("startAt") Long startAt, + @QueryParam("endAt") Long endAt, + @QueryParam("pageSize") int pageSize, + @QueryParam("currentPage") int currentPage) + throws IOException; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/OrderBookAPI.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/OrderBookAPI.java new file mode 100644 index 00000000000..2e126360e69 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/OrderBookAPI.java @@ -0,0 +1,64 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.service; + +import java.io.IOException; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import org.knowm.xchange.kucoin.dto.response.KucoinResponse; +import org.knowm.xchange.kucoin.dto.response.OrderBookResponse; + +/** Based on code by chenshiwei on 2019/1/22. */ +@Path("api/v1/market/orderbook") +@Produces(MediaType.APPLICATION_JSON) +public interface OrderBookAPI { + + /** + * Get a list of open orders for a symbol. Level-2 order book includes all bids and asks + * (aggregated by price), this level return only one size for each active price (as if there was + * only a single order for that size at the level). This API will return a part of Order Book + * within 100 depth for each side(ask or bid). It is recommended to use in most cases, it is the + * fastest Order Book API, and reduces traffic usage. To maintain up-to-date Order Book in real + * time, please use it with Websocket Feed. + * + * @param symbol The symbol whose order book should be fetched. + * @return The aggregated part order book. + */ + @GET + @Path("/level2_100") + KucoinResponse getPartOrderBookAggregated(@QueryParam("symbol") String symbol) + throws IOException; + + /** + * Get a list of open orders for a symbol. Level-2 order book includes all bids and asks + * (aggregated by price), this level return only one size for each active price (as if there was + * only a single order for that size at the level). This API will return data with full depth. It + * is generally used by professional traders because it uses more server resources and traffic, + * and we have strict access frequency control. To maintain up-to-date Order Book in real time, + * please use it with Websocket Feed. + * + * @param symbol The symbol whose order book should be fetched. + * @return The aggregated full order book. + */ + @GET + @Path("/level2") + KucoinResponse getFullOrderBookAggregated(@QueryParam("symbol") String symbol) + throws IOException; + + /** + * Get a list of open orders for a symbol. Level-3 order book includes all bids and asks + * (non-aggregated, each item in Level-3 means a single order). Level 3 is non-aggregated and + * returns the entire order book. This API is generally used by professional traders because it + * uses more server resources and traffic, and we have strict access frequency control. To + * Maintain up-to-date Order Book in real time, please use it with Websocket Feed. + * + * @param symbol The symbol whose order book should be fetched. + * @return The full atomic order book. + */ + @GET + @Path("/level3") + KucoinResponse getFullOrderBookAtomic(@QueryParam("symbol") String symbol) + throws IOException; +} diff --git a/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/SymbolAPI.java b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/SymbolAPI.java new file mode 100644 index 00000000000..6c72e0542a3 --- /dev/null +++ b/xchange-kucoin/src/main/java/org/knowm/xchange/kucoin/service/SymbolAPI.java @@ -0,0 +1,51 @@ +/** Copyright 2019 Mek Global Limited. */ +package org.knowm.xchange.kucoin.service; + +import java.io.IOException; +import java.util.List; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import org.knowm.xchange.kucoin.dto.response.KucoinResponse; +import org.knowm.xchange.kucoin.dto.response.SymbolResponse; +import org.knowm.xchange.kucoin.dto.response.SymbolTickResponse; +import org.knowm.xchange.kucoin.dto.response.TickerResponse; + +/** Based on code by chenshiwei on 2019/1/11. */ +@Path("api/v1") +@Produces(MediaType.APPLICATION_JSON) +public interface SymbolAPI { + + /** + * Get a list of available currency pairs for trading. + * + * @return The available symbols. + */ + @GET + @Path("/symbols") + KucoinResponse> getSymbols() throws IOException; + + /** + * Ticker include only the inside (i.e. best) bid and ask data , last price and last trade size. + * + * @param symbol The currency + * @return The ticker. + */ + @GET + @Path("/market/orderbook/level1") + KucoinResponse getTicker(@QueryParam("symbol") String symbol) throws IOException; + + /** + * Get 24 hr stats for the symbol. volume is in base currency units. open, high, low are in quote + * currency units. + * + * @param symbol The symbol to fetch. + * @return The 24hr stats for the symbol. + */ + @GET + @Path("/market/stats") + KucoinResponse getMarketStats(@QueryParam("symbol") String symbol) + throws IOException; +} diff --git a/xchange-simulated/pom.xml b/xchange-simulated/pom.xml new file mode 100644 index 00000000000..8e4e681f9ef --- /dev/null +++ b/xchange-simulated/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + + org.knowm.xchange + xchange-parent + 4.3.17-SNAPSHOT + + + xchange-simulated + + XChange Simulated + A simulated/mock exchange, for use in integration testing. + + http://knowm.org/open-source/xchange/ + 2012 + + + Knowm Inc. + http://knowm.org/open-source/xchange/ + + + + + + org.knowm.xchange + xchange-core + ${project.version} + + + com.google.guava + guava + + + org.projectlombok + lombok + + + org.mockito + mockito-core + + + diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/Account.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/Account.java new file mode 100644 index 00000000000..d7af0f4119d --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/Account.java @@ -0,0 +1,194 @@ +package org.knowm.xchange.simulated; + +import static java.math.BigDecimal.ZERO; + +import com.google.common.collect.Collections2; +import java.math.BigDecimal; +import java.util.Collection; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicReference; +import org.knowm.xchange.currency.Currency; +import org.knowm.xchange.dto.Order; +import org.knowm.xchange.dto.account.Balance; +import org.knowm.xchange.dto.trade.LimitOrder; +import org.knowm.xchange.dto.trade.UserTrade; +import org.knowm.xchange.exceptions.ExchangeException; +import org.knowm.xchange.exceptions.FundsExceededException; +import org.knowm.xchange.exceptions.NotAvailableFromExchangeException; + +class Account { + + private final ConcurrentMap> balances = + new ConcurrentHashMap<>(); + + void initialize(Iterable currencies) { + currencies.forEach( + currency -> balances.put(currency, new AtomicReference<>(new Balance(currency, ZERO)))); + } + + public Collection balances() { + return Collections2.transform(balances.values(), AtomicReference::get); + } + + public void checkBalance(LimitOrder order) { + checkBalance(order, order.getOriginalAmount().multiply(order.getLimitPrice())); + } + + public void checkBalance(Order order, BigDecimal bidAmount) { + switch (order.getType()) { + case ASK: + BigDecimal askAmount = order.getRemainingAmount(); + Balance askBalance = + balances.computeIfAbsent(order.getCurrencyPair().base, this::defaultBalance).get(); + checkBalance(order, askAmount, askBalance); + break; + case BID: + Balance bidBalance = + balances.computeIfAbsent(order.getCurrencyPair().counter, this::defaultBalance).get(); + checkBalance(order, bidAmount, bidBalance); + break; + default: + throw new NotAvailableFromExchangeException( + "Order type " + order.getType() + " not supported"); + } + } + + private void checkBalance(Order order, BigDecimal amount, Balance balance) { + if (balance.getAvailable().compareTo(amount) < 0) { + throw new FundsExceededException( + "Insufficient balance: " + + amount.toPlainString() + + order.getCurrencyPair().base + + " required but only " + + balance.getAvailable() + + " available"); + } + } + + public void reserve(LimitOrder order) { + reserve(order, false); + } + + public void release(LimitOrder order) { + reserve(order, true); + } + + private AtomicReference defaultBalance(Currency currency) { + return new AtomicReference<>(new Balance(currency, ZERO)); + } + + private void reserve(LimitOrder order, boolean negate) { + switch (order.getType()) { + case ASK: + BigDecimal askAmount = + negate ? order.getRemainingAmount().negate() : order.getRemainingAmount(); + balance(order.getCurrencyPair().base) + .updateAndGet( + b -> { + if (b.getAvailable().compareTo(askAmount) < 0) { + throw new ExchangeException( + "Insufficient balance: " + + askAmount.toPlainString() + + order.getCurrencyPair().base + + " required but only " + + b.getAvailable() + + " available"); + } + return Balance.Builder.from(b) + .available(b.getAvailable().subtract(askAmount)) + .frozen(b.getFrozen().add(askAmount)) + .build(); + }); + break; + case BID: + BigDecimal bid = order.getRemainingAmount().multiply(order.getLimitPrice()); + BigDecimal bidAmount = negate ? bid.negate() : bid; + balance(order.getCurrencyPair().counter) + .updateAndGet( + b -> { + if (b.getAvailable().compareTo(bidAmount) < 0) { + throw new ExchangeException( + "Insufficient balance: " + + bidAmount.toPlainString() + + order.getCurrencyPair().counter + + " required but only " + + b.getAvailable() + + " available"); + } + return Balance.Builder.from(b) + .available(b.getAvailable().subtract(bidAmount)) + .frozen(b.getFrozen().add(bidAmount)) + .build(); + }); + break; + default: + throw new NotAvailableFromExchangeException( + "Order type " + order.getType() + " not supported"); + } + } + + public void fill(UserTrade userTrade, boolean reserved) { + BigDecimal counterAmount = userTrade.getOriginalAmount().multiply(userTrade.getPrice()); + switch (userTrade.getType()) { + case ASK: + balance(userTrade.getCurrencyPair().base) + .updateAndGet( + b -> + Balance.Builder.from(b) + .available( + reserved + ? b.getAvailable() + : b.getAvailable().subtract(userTrade.getOriginalAmount())) + .frozen( + reserved + ? b.getFrozen().subtract(userTrade.getOriginalAmount()) + : b.getFrozen()) + .total(b.getTotal().subtract(userTrade.getOriginalAmount())) + .build()); + balance(userTrade.getCurrencyPair().counter) + .updateAndGet( + b -> + Balance.Builder.from(b) + .total(b.getTotal().add(counterAmount)) + .available(b.getAvailable().add(counterAmount)) + .build()); + break; + case BID: + balance(userTrade.getCurrencyPair().base) + .updateAndGet( + b -> + Balance.Builder.from(b) + .total(b.getTotal().add(userTrade.getOriginalAmount())) + .available(b.getAvailable().add(userTrade.getOriginalAmount())) + .build()); + balance(userTrade.getCurrencyPair().counter) + .updateAndGet( + b -> + Balance.Builder.from(b) + .available( + reserved ? b.getAvailable() : b.getAvailable().subtract(counterAmount)) + .frozen(reserved ? b.getFrozen().subtract(counterAmount) : b.getFrozen()) + .total(b.getTotal().subtract(counterAmount)) + .build()); + break; + default: + throw new NotAvailableFromExchangeException( + "Order type " + userTrade.getType() + " not supported"); + } + } + + private AtomicReference balance(Currency currency) { + return balances.computeIfAbsent(currency, this::defaultBalance); + } + + public void deposit(Currency currency, BigDecimal amount) { + balance(currency) + .updateAndGet( + b -> + Balance.Builder.from(b) + .total(b.getTotal().add(amount)) + .available(b.getAvailable().add(amount)) + .build()); + } +} diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/AccountFactory.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/AccountFactory.java new file mode 100644 index 00000000000..ed4ec90b843 --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/AccountFactory.java @@ -0,0 +1,24 @@ +package org.knowm.xchange.simulated; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.knowm.xchange.ExchangeSpecification; + +/** + * An instance of {@link AccountFactory} represents a single set of user accounts. A user account is + * identified by its {@link ExchangeSpecification#getApiKey()} and consist of a set of per-currency + * balances. + * + *

If shared between {@link SimulatedExchange} instances, this ensures that they all share the + * same scope of user accounts. + * + * @author Graham Crockford + */ +public class AccountFactory { + + private final ConcurrentMap accounts = new ConcurrentHashMap<>(); + + Account get(String apiKey) { + return accounts.computeIfAbsent(apiKey, key -> new Account()); + } +} diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/BookLevel.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/BookLevel.java new file mode 100644 index 00000000000..c884315e374 --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/BookLevel.java @@ -0,0 +1,12 @@ +package org.knowm.xchange.simulated; + +import java.math.BigDecimal; +import java.util.LinkedList; +import java.util.List; +import lombok.Data; + +@Data +final class BookLevel { + private final BigDecimal price; + private final List orders = new LinkedList<>(); +} diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/BookOrder.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/BookOrder.java new file mode 100644 index 00000000000..4ce61105daf --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/BookOrder.java @@ -0,0 +1,83 @@ +package org.knowm.xchange.simulated; + +import static java.math.BigDecimal.ZERO; +import static java.util.UUID.randomUUID; +import static org.knowm.xchange.dto.Order.OrderType.ASK; + +import java.math.BigDecimal; +import java.util.Date; +import lombok.Builder; +import lombok.Data; +import org.knowm.xchange.currency.CurrencyPair; +import org.knowm.xchange.dto.Order; +import org.knowm.xchange.dto.Order.OrderStatus; +import org.knowm.xchange.dto.Order.OrderType; +import org.knowm.xchange.dto.trade.LimitOrder; + +/** + * An order placed on the {@link SimulatedExchange} order book. + * + * @author Graham Crockford + */ +@Data +@Builder +final class BookOrder { + + private static final BigDecimal INF = BigDecimal.valueOf(Long.MAX_VALUE); + + static BookOrder fromOrder(Order original, String apiKey) { + return BookOrder.builder() + .apiKey(apiKey) + .id(randomUUID().toString()) + .limitPrice( + original instanceof LimitOrder + ? ((LimitOrder) original).getLimitPrice() + : original.getType() == ASK ? ZERO : INF) + .originalAmount(original.getOriginalAmount()) + .timestamp(new Date()) + .type(original.getType()) + .build(); + } + + private final String apiKey; + private final BigDecimal originalAmount; + private final String id; + private final Date timestamp; + private final BigDecimal limitPrice; + private final OrderType type; + @Builder.Default private volatile BigDecimal cumulativeAmount = ZERO; + private volatile BigDecimal averagePrice; + @Builder.Default private volatile BigDecimal fee = ZERO; + + BigDecimal getRemainingAmount() { + return originalAmount.subtract(cumulativeAmount); + } + + boolean isDone() { + return originalAmount.compareTo(cumulativeAmount) == 0; + } + + boolean matches(BookOrder takerOrder) { + return type == ASK + ? limitPrice.compareTo(takerOrder.getLimitPrice()) <= 0 + : limitPrice.compareTo(takerOrder.getLimitPrice()) >= 0; + } + + LimitOrder toOrder(CurrencyPair currencyPair) { + return new LimitOrder.Builder(type, currencyPair) + .id(id) + .averagePrice(averagePrice) + .cumulativeAmount(cumulativeAmount) + .fee(fee) + .limitPrice(limitPrice) + .orderStatus( + cumulativeAmount.compareTo(ZERO) == 0 + ? OrderStatus.NEW + : cumulativeAmount.compareTo(originalAmount) == 0 + ? OrderStatus.FILLED + : OrderStatus.PARTIALLY_FILLED) + .originalAmount(originalAmount) + .timestamp(timestamp) + .build(); + } +} diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/Fill.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/Fill.java new file mode 100644 index 00000000000..823a8b818d4 --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/Fill.java @@ -0,0 +1,17 @@ +package org.knowm.xchange.simulated; + +import lombok.Data; +import org.knowm.xchange.dto.trade.UserTrade; + +/** + * Represents a trade against a {@link Level3OrderBook} for a given {@link #getApiKey()} (user), + * indicating whether the fill was executed as the market maker. + * + * @author Graham Crockford + */ +@Data +final class Fill { + private final String apiKey; + private final UserTrade trade; + private final boolean taker; +} diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/Level3OrderBook.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/Level3OrderBook.java new file mode 100644 index 00000000000..e9355859502 --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/Level3OrderBook.java @@ -0,0 +1,17 @@ +package org.knowm.xchange.simulated; + +import java.util.List; +import lombok.Data; +import org.knowm.xchange.dto.trade.LimitOrder; + +/** + * A full order book, consisting of every single limit order on the book on both the ask and bid + * sides. + * + * @author Graham Crockford + */ +@Data +public class Level3OrderBook { + private final List asks; + private final List bids; +} diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/MatchingEngine.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/MatchingEngine.java new file mode 100644 index 00000000000..841ffd34372 --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/MatchingEngine.java @@ -0,0 +1,391 @@ +package org.knowm.xchange.simulated; + +import static java.math.BigDecimal.ZERO; +import static java.math.RoundingMode.HALF_UP; +import static java.util.UUID.randomUUID; +import static java.util.stream.Collectors.toList; +import static org.knowm.xchange.dto.Order.OrderType.ASK; +import static org.knowm.xchange.dto.Order.OrderType.BID; + +import com.google.common.collect.FluentIterable; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.LinkedListMultimap; +import com.google.common.collect.Multimap; +import com.google.common.collect.Ordering; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Date; +import java.util.Deque; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.knowm.xchange.currency.CurrencyPair; +import org.knowm.xchange.dto.Order; +import org.knowm.xchange.dto.Order.OrderType; +import org.knowm.xchange.dto.marketdata.OrderBook; +import org.knowm.xchange.dto.marketdata.Ticker; +import org.knowm.xchange.dto.marketdata.Trade; +import org.knowm.xchange.dto.trade.LimitOrder; +import org.knowm.xchange.dto.trade.MarketOrder; +import org.knowm.xchange.dto.trade.UserTrade; +import org.knowm.xchange.exceptions.ExchangeException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The "exchange" which backs {@link SimulatedExchange}. + * + * @author Graham Crockford + */ +final class MatchingEngine { + + private static final Logger LOGGER = LoggerFactory.getLogger(MatchingEngine.class); + private static final BigDecimal FEE_RATE = new BigDecimal("0.001"); + private static final int TRADE_HISTORY_SIZE = 50; + + private final AccountFactory accountFactory; + private final CurrencyPair currencyPair; + private final int priceScale; + private final BigDecimal minimumAmount; + private final Consumer onFill; + + private final List asks = new LinkedList<>(); + private final List bids = new LinkedList<>(); + private final Deque publicTrades = new ConcurrentLinkedDeque<>(); + private final Multimap userTrades = LinkedListMultimap.create(); + + private volatile Ticker ticker = new Ticker.Builder().build(); + + MatchingEngine( + AccountFactory accountFactory, + CurrencyPair currencyPair, + int priceScale, + BigDecimal minimumAmount) { + this(accountFactory, currencyPair, priceScale, minimumAmount, f -> {}); + } + + MatchingEngine( + AccountFactory accountFactory, + CurrencyPair currencyPair, + int priceScale, + BigDecimal minimumAmount, + Consumer onFill) { + this.accountFactory = accountFactory; + this.currencyPair = currencyPair; + this.priceScale = priceScale; + this.minimumAmount = minimumAmount; + this.onFill = onFill; + } + + public synchronized LimitOrder postOrder(String apiKey, Order original) { + LOGGER.debug("User {} posting order: {}", apiKey, original); + validate(original); + Account account = accountFactory.get(apiKey); + checkBalance(original, account); + BookOrder takerOrder = BookOrder.fromOrder(original, apiKey); + switch (takerOrder.getType()) { + case ASK: + LOGGER.debug("Matching against bids"); + chewBook(bids, takerOrder); + if (!takerOrder.isDone()) { + if (original instanceof MarketOrder) { + throw new ExchangeException("Cannot fulfil order. No buyers."); + } + insertIntoBook(asks, takerOrder, ASK, account); + } + break; + case BID: + LOGGER.debug("Matching against asks"); + chewBook(asks, takerOrder); + if (!takerOrder.isDone()) { + if (original instanceof MarketOrder) { + throw new ExchangeException("Cannot fulfil order. No sellers."); + } + insertIntoBook(bids, takerOrder, BID, account); + } + break; + default: + throw new ExchangeException("Unsupported order type: " + takerOrder.getType()); + } + return takerOrder.toOrder(currencyPair); + } + + private void validate(Order order) { + if (order.getOriginalAmount().compareTo(minimumAmount) < 0) { + throw new ExchangeException( + "Trade amount is " + order.getOriginalAmount() + ", minimum is " + minimumAmount); + } + if (order instanceof LimitOrder) { + LimitOrder limitOrder = (LimitOrder) order; + if (limitOrder.getLimitPrice() == null) { + throw new ExchangeException("No price"); + } + if (limitOrder.getLimitPrice().compareTo(ZERO) <= 0) { + throw new ExchangeException( + "Limit price is " + limitOrder.getLimitPrice() + ", must be positive"); + } + int scale = limitOrder.getLimitPrice().stripTrailingZeros().scale(); + if (scale > priceScale) { + throw new ExchangeException("Price scale is " + scale + ", maximum is " + priceScale); + } + } + } + + private void checkBalance(Order order, Account account) { + if (order instanceof LimitOrder) { + account.checkBalance((LimitOrder) order); + } else { + BigDecimal marketCostOrProceeds = + marketCostOrProceeds(order.getType(), order.getOriginalAmount()); + BigDecimal marketAmount = + order.getType().equals(OrderType.BID) ? marketCostOrProceeds : order.getOriginalAmount(); + account.checkBalance(order, marketAmount); + } + } + + private void insertIntoBook( + List book, BookOrder order, OrderType type, Account account) { + + int i = 0; + boolean insert = false; + + Iterator iter = book.iterator(); + while (iter.hasNext()) { + BookLevel level = iter.next(); + int signum = level.getPrice().compareTo(order.getLimitPrice()); + if (signum == 0) { + level.getOrders().add(order); + return; + } else if (signum < 0 && type == BID || signum > 0 && type == ASK) { + insert = true; + break; + } + i++; + } + + account.reserve(order.toOrder(currencyPair)); + + BookLevel newLevel = new BookLevel(order.getLimitPrice()); + newLevel.getOrders().add(order); + if (insert) { + book.add(i, newLevel); + } else { + book.add(newLevel); + } + + ticker = newTickerFromBook().last(ticker.getLast()).build(); + } + + private Ticker.Builder newTickerFromBook() { + return new Ticker.Builder() + .ask(asks.isEmpty() ? null : asks.get(0).getPrice()) + .bid(bids.isEmpty() ? null : bids.get(0).getPrice()); + } + + /** + * Calculates the total cost or proceeds at market price of the specified bid/ask amount. + * + * @param orderType Ask or bid. + * @param amount The amount. + * @return The market cost/proceeds + * @throws ExchangeException If there is insufficient liquidity. + */ + public BigDecimal marketCostOrProceeds(OrderType orderType, BigDecimal amount) { + BigDecimal remaining = amount; + BigDecimal cost = ZERO; + List orderbookSide = orderType.equals(BID) ? asks : bids; + for (BookOrder order : + FluentIterable.from(orderbookSide).transformAndConcat(BookLevel::getOrders)) { + BigDecimal available = order.getRemainingAmount(); + BigDecimal tradeAmount = remaining.compareTo(available) >= 0 ? available : remaining; + BigDecimal tradeCost = tradeAmount.multiply(order.getLimitPrice()); + cost = cost.add(tradeCost); + remaining = remaining.subtract(tradeAmount); + if (remaining.compareTo(ZERO) == 0) return cost; + } + throw new ExchangeException("Insufficient liquidity in book"); + } + + public synchronized Level3OrderBook book() { + return new Level3OrderBook( + FluentIterable.from(asks) + .transformAndConcat(BookLevel::getOrders) + .transform(o -> o.toOrder(currencyPair)) + .toList(), + FluentIterable.from(bids) + .transformAndConcat(BookLevel::getOrders) + .transform(o -> o.toOrder(currencyPair)) + .toList()); + } + + public Ticker ticker() { + return ticker; + } + + public List publicTrades() { + return FluentIterable.from(publicTrades).transform(t -> Trade.Builder.from(t).build()).toList(); + } + + public synchronized List tradeHistory(String apiKey) { + return ImmutableList.copyOf(userTrades.get(apiKey)); + } + + private void chewBook(Iterable makerOrders, BookOrder takerOrder) { + Iterator levelIter = makerOrders.iterator(); + while (levelIter.hasNext()) { + BookLevel level = levelIter.next(); + Iterator orderIter = level.getOrders().iterator(); + while (orderIter.hasNext() && !takerOrder.isDone()) { + BookOrder makerOrder = orderIter.next(); + + LOGGER.debug("Matching against maker order {}", makerOrder); + if (!makerOrder.matches(takerOrder)) { + LOGGER.debug("Ran out of maker orders at this price"); + return; + } + + BigDecimal tradeAmount = + takerOrder.getRemainingAmount().compareTo(makerOrder.getRemainingAmount()) > 0 + ? makerOrder.getRemainingAmount() + : takerOrder.getRemainingAmount(); + + LOGGER.debug("Matches for {}", tradeAmount); + matchOff(takerOrder, makerOrder, tradeAmount); + + if (makerOrder.isDone()) { + LOGGER.debug("Maker order removed from book"); + orderIter.remove(); + if (level.getOrders().isEmpty()) { + levelIter.remove(); + } + } + } + } + } + + private void matchOff(BookOrder takerOrder, BookOrder makerOrder, BigDecimal tradeAmount) { + Date timestamp = new Date(); + + UserTrade takerTrade = + new UserTrade.Builder() + .currencyPair(currencyPair) + .id(randomUUID().toString()) + .originalAmount(tradeAmount) + .price(makerOrder.getLimitPrice()) + .timestamp(timestamp) + .type(takerOrder.getType()) + .orderId(takerOrder.getId()) + .feeAmount( + takerOrder.getType() == ASK + ? tradeAmount.multiply(makerOrder.getLimitPrice()).multiply(FEE_RATE) + : tradeAmount.multiply(FEE_RATE)) + .feeCurrency(takerOrder.getType() == ASK ? currencyPair.counter : currencyPair.base) + .build(); + + LOGGER.debug("Created taker trade: {}", takerTrade); + + accumulate(takerOrder, takerTrade); + + OrderType makerType = takerOrder.getType() == OrderType.ASK ? OrderType.BID : OrderType.ASK; + UserTrade makerTrade = + new UserTrade.Builder() + .currencyPair(currencyPair) + .id(randomUUID().toString()) + .originalAmount(tradeAmount) + .price(makerOrder.getLimitPrice()) + .timestamp(timestamp) + .type(makerType) + .orderId(makerOrder.getId()) + .feeAmount( + makerType == ASK + ? tradeAmount.multiply(makerOrder.getLimitPrice()).multiply(FEE_RATE) + : tradeAmount.multiply(FEE_RATE)) + .feeCurrency(makerType == ASK ? currencyPair.counter : currencyPair.base) + .build(); + + LOGGER.debug("Created maker trade: {}", makerOrder); + accumulate(makerOrder, makerTrade); + + recordFill(new Fill(takerOrder.getApiKey(), takerTrade, true)); + recordFill(new Fill(makerOrder.getApiKey(), makerTrade, false)); + + ticker = newTickerFromBook().last(makerOrder.getLimitPrice()).build(); + } + + private void accumulate(BookOrder bookOrder, UserTrade trade) { + BigDecimal amount = trade.getOriginalAmount(); + BigDecimal price = trade.getPrice(); + BigDecimal newTotal = bookOrder.getCumulativeAmount().add(amount); + + if (bookOrder.getCumulativeAmount().compareTo(ZERO) == 0) { + bookOrder.setAveragePrice(price); + } else { + bookOrder.setAveragePrice( + bookOrder + .getAveragePrice() + .multiply(bookOrder.getCumulativeAmount()) + .add(price.multiply(amount)) + .divide(newTotal, priceScale, HALF_UP)); + } + + bookOrder.setCumulativeAmount(newTotal); + bookOrder.setFee(bookOrder.getFee().add(trade.getFeeAmount())); + } + + public synchronized List openOrders(String apiKey) { + return Stream.concat(asks.stream(), bids.stream()) + .flatMap(v -> v.getOrders().stream()) + .filter(o -> o.getApiKey().equals(apiKey)) + .sorted(Ordering.natural().onResultOf(BookOrder::getTimestamp).reversed()) + .map(o -> o.toOrder(currencyPair)) + .collect(toList()); + } + + public synchronized OrderBook level2() { + return new OrderBook(new Date(), accumulateBookSide(asks), accumulateBookSide(bids)); + } + + private List accumulateBookSide(List book) { + BigDecimal price = null; + BigDecimal amount = ZERO; + List result = new ArrayList<>(); + Iterator iter = book.stream().flatMap(v -> v.getOrders().stream()).iterator(); + while (iter.hasNext()) { + BookOrder bookOrder = iter.next(); + amount = amount.add(bookOrder.getRemainingAmount()); + if (price != null && bookOrder.getLimitPrice().compareTo(price) != 0) { + result.add( + new LimitOrder.Builder(ASK, currencyPair) + .originalAmount(amount) + .limitPrice(price) + .build()); + amount = ZERO; + } + price = bookOrder.getLimitPrice(); + } + if (price != null) { + result.add( + new LimitOrder.Builder(ASK, currencyPair) + .originalAmount(amount) + .limitPrice(price) + .build()); + } + return result; + } + + private void recordFill(Fill fill) { + // XChange is unusual in this respect (see https://github.com/knowm/XChange/issues/2468) + if (!fill.isTaker()) { + publicTrades.push(fill.getTrade()); + if (publicTrades.size() > TRADE_HISTORY_SIZE) { + publicTrades.removeLast(); + } + } + userTrades.put(fill.getApiKey(), fill.getTrade()); + accountFactory.get(fill.getApiKey()).fill(fill.getTrade(), !fill.isTaker()); + onFill.accept(fill); + } +} diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/MatchingEngineFactory.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/MatchingEngineFactory.java new file mode 100644 index 00000000000..f8cba661890 --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/MatchingEngineFactory.java @@ -0,0 +1,39 @@ +package org.knowm.xchange.simulated; + +import java.math.BigDecimal; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Consumer; +import org.knowm.xchange.currency.CurrencyPair; + +/** + * Represents a single virtual cryptocurrency exchange - effectively a set of order books for each + * currency where trades can be placed as maker orders and taker orders can be matched. + * + *

If shared between instances of {@link SimulatedExchange}, this ensures that all users will be + * trading against the same order books and thus each other. + * + * @author Graham Crockford + */ +public class MatchingEngineFactory { + + private final ConcurrentMap engines = new ConcurrentHashMap<>(); + + private final AccountFactory accountFactory; + + public MatchingEngineFactory(AccountFactory accountFactory) { + this.accountFactory = accountFactory; + } + + MatchingEngine create( + CurrencyPair currencyPair, int priceScale, BigDecimal minimumAmount, Consumer onFill) { + return engines.computeIfAbsent( + currencyPair, + pair -> new MatchingEngine(accountFactory, pair, priceScale, minimumAmount, onFill)); + } + + MatchingEngine create(CurrencyPair currencyPair, int priceScale, BigDecimal minimumAmount) { + return engines.computeIfAbsent( + currencyPair, pair -> new MatchingEngine(accountFactory, pair, priceScale, minimumAmount)); + } +} diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/RandomExceptionThrower.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/RandomExceptionThrower.java new file mode 100644 index 00000000000..f150f0fdfe0 --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/RandomExceptionThrower.java @@ -0,0 +1,73 @@ +package org.knowm.xchange.simulated; + +import com.google.common.util.concurrent.RateLimiter; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.util.Random; +import org.apache.commons.lang3.RandomUtils; +import org.knowm.xchange.ExchangeSpecification; +import org.knowm.xchange.exceptions.FrequencyLimitExceededException; +import org.knowm.xchange.exceptions.NonceException; +import org.knowm.xchange.exceptions.RateLimitExceededException; +import org.knowm.xchange.exceptions.SystemOverloadException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This will cause {@link SimulatedExchange} to fail 0.5% of the time with a selection of commnplace + * transient issues which could happen at any time in real life and should therefore be handled + * gracefully in client code. Pass this to {@link + * ExchangeSpecification#getExchangeSpecificParametersItem(String)} using the parameter name {@link + * SimulatedExchange#ON_OPERATION_PARAM} during long-running integration testing to inject an + * appropriate bit of chaos into proceedings. + * + * @author Graham Crockford + */ +public class RandomExceptionThrower implements SimulatedExchangeOperationListener { + + private static final Logger LOGGER = LoggerFactory.getLogger(MatchingEngine.class); + private static final String GENERIC_GUIDE = "Application code should handle this gracefully."; + private static final String RATE_LIMIT_EXCEEDED = + "Rate limit exceeded. Are you gracefully backing off when this happens?"; + + private final Random random; + private final RateLimiter rateLimiter; + + /** Uses a random seed derived from the system clock. */ + public RandomExceptionThrower() { + this(23423212554L ^ System.nanoTime()); + } + + /** + * Uses a specified random seed + * + * @param seed the random seed. + */ + public RandomExceptionThrower(long seed) { + this.random = new Random(seed); + this.rateLimiter = RateLimiter.create(5.2); // slightly higher than the published limit + LOGGER.info( + "Simulated exchange will fire random transient exceptions, with random seed: {}", seed); + } + + @Override + public void onSimulatedExchangeOperation() throws IOException { + int val = random.nextInt(1000); + if (val == 1) { + throw new NonceException("Exchanges often complain about nonce issues. " + GENERIC_GUIDE); + } else if (val == 2) { + throw new SocketTimeoutException( + "Socket timeouts connecting to exchanges are commonplace. " + GENERIC_GUIDE); + } else if (val == 3) { + throw new SystemOverloadException( + "System overloads are a common error on some exchanges. " + GENERIC_GUIDE); + } + if (!rateLimiter.tryAcquire()) { + if (RandomUtils.nextBoolean()) { + throw new RateLimitExceededException(RATE_LIMIT_EXCEEDED); + } else { + throw new FrequencyLimitExceededException(RATE_LIMIT_EXCEEDED); + } + } + } +} diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedAccountService.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedAccountService.java new file mode 100644 index 00000000000..21af9655c93 --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedAccountService.java @@ -0,0 +1,27 @@ +package org.knowm.xchange.simulated; + +import java.io.IOException; +import java.math.BigDecimal; +import org.knowm.xchange.currency.Currency; +import org.knowm.xchange.dto.account.AccountInfo; +import org.knowm.xchange.dto.account.Wallet; +import org.knowm.xchange.service.BaseExchangeService; +import org.knowm.xchange.service.account.AccountService; + +public class SimulatedAccountService extends BaseExchangeService + implements AccountService { + + protected SimulatedAccountService(SimulatedExchange exchange) { + super(exchange); + } + + @Override + public AccountInfo getAccountInfo() throws IOException { + exchange.maybeThrow(); + return new AccountInfo(new Wallet(exchange.getAccount().balances())); + } + + public void deposit(Currency currency, BigDecimal amount) { + exchange.getAccount().deposit(currency, amount); + } +} diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedExchange.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedExchange.java new file mode 100644 index 00000000000..c46b72bee09 --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedExchange.java @@ -0,0 +1,146 @@ +package org.knowm.xchange.simulated; + +import static java.math.BigDecimal.ZERO; + +import java.io.IOException; +import org.apache.commons.lang3.StringUtils; +import org.knowm.xchange.BaseExchange; +import org.knowm.xchange.ExchangeSpecification; +import org.knowm.xchange.currency.CurrencyPair; +import org.knowm.xchange.dto.meta.CurrencyPairMetaData; +import org.knowm.xchange.exceptions.CurrencyPairNotValidException; +import org.knowm.xchange.exceptions.ExchangeException; +import org.knowm.xchange.exceptions.ExchangeSecurityException; +import si.mazi.rescu.SynchronizedValueFactory; + +/** + * A simple, in-memory implementation which mocks out the main elements of the XChange generic API + * in a consistent way. The effect is to create a virtual "exchange" that you can connect to from + * multiple threads and simulate a real exchange. Intended for integration testing of higher order + * components. + * + *

This is not remotely suitable for use as a real-world exchange. The concurrency is extremely + * coarse-grained and most data transforms involve data mutation with no . If any errors occur + * midway through a trade, they are likely to leave the system in an inconsistent state. And nothing + * gets saved to a database anyway. + * + *

+ * + *

If you start using this for running a real exchange, you will suffer a + * stolen. + * + * @author Graham Crockford + */ +public class SimulatedExchange extends BaseExchange { + + /** + * Allows the scope of the simulated exchange to be controlled. Pass to {@link + * ExchangeSpecification#setExchangeSpecificParametersItem(String, Object)} to choose one of: + * + *

    + *
  • {@code new MatchingEngineFactory()} - default. Each instance of {@link + * SimulatedExchange} works on a different virtual exchange with its own set of order books, + * thus the simulated exchange is single-user. Recommended for unit testing. + *
  • An existing, shared instance of {@code MatchingEngineFactory} - Create your own factory + * and share it between {@link SimulatedExchange} instances to allow those specific + * instances to share the same order books and thus trade against each other. Recommended + * for integration testing. + *
+ */ + public static final String ENGINE_FACTORY_PARAM = "MatchingEngineFactory"; + + /** + * As with {@link #ENGINE_FACTORY_PARAM}, provides a default unshared but optionally shared + * instance of {@link AccountFactory}. + */ + public static final String ACCOUNT_FACTORY_PARAM = "AccountFactory"; + + /** Provides a {@link SimulatedExchangeOperationListener}. */ + public static final String ON_OPERATION_PARAM = "OnExchangeOperation"; + + private MatchingEngineFactory engineFactory; + private AccountFactory accountFactory; + private SimulatedExchangeOperationListener exceptionThrower; + + @Override + public SynchronizedValueFactory getNonceFactory() { + throw new UnsupportedOperationException("Nonce factory is not used."); + } + + @Override + public ExchangeSpecification getDefaultExchangeSpecification() { + ExchangeSpecification exchangeSpecification = + new ExchangeSpecification(this.getClass().getCanonicalName()); + exchangeSpecification.setExchangeName("Simulated"); + exchangeSpecification.setExchangeDescription( + "A simulated exchange for integration testing purposes."); + AccountFactory accountFactory = new AccountFactory(); + exchangeSpecification.setExchangeSpecificParametersItem( + ENGINE_FACTORY_PARAM, new MatchingEngineFactory(accountFactory)); + exchangeSpecification.setExchangeSpecificParametersItem(ACCOUNT_FACTORY_PARAM, accountFactory); + exchangeSpecification.setExchangeSpecificParametersItem( + ON_OPERATION_PARAM, (SimulatedExchangeOperationListener) () -> {}); + return exchangeSpecification; + } + + @Override + protected void initServices() { + engineFactory = + (MatchingEngineFactory) + exchangeSpecification.getExchangeSpecificParametersItem(ENGINE_FACTORY_PARAM); + accountFactory = + (AccountFactory) + exchangeSpecification.getExchangeSpecificParametersItem(ACCOUNT_FACTORY_PARAM); + exceptionThrower = + (SimulatedExchangeOperationListener) + exchangeSpecification.getExchangeSpecificParametersItem(ON_OPERATION_PARAM); + tradeService = new SimulatedTradeService(this); + marketDataService = new SimulatedMarketDataService(this); + accountService = new SimulatedAccountService(this); + } + + @Override + public void remoteInit() throws IOException, ExchangeException { + if (StringUtils.isNotEmpty(exchangeSpecification.getApiKey())) + getAccount().initialize(getExchangeMetaData().getCurrencies().keySet()); + } + + Account getAccount() { + if (StringUtils.isEmpty(exchangeSpecification.getApiKey())) + throw new ExchangeSecurityException("API key required for account access"); + return accountFactory.get(exchangeSpecification.getApiKey()); + } + + void maybeThrow() throws IOException { + exceptionThrower.onSimulatedExchangeOperation(); + } + + MatchingEngine getEngine(CurrencyPair currencyPair) { + CurrencyPairMetaData currencyPairMetaData = + getExchangeMetaData().getCurrencyPairs().get(currencyPair); + if (currencyPairMetaData == null) { + throw new CurrencyPairNotValidException( + "Currency pair " + currencyPair + " not known", currencyPair); + } + return engineFactory.create( + currencyPair, + currencyPairMetaData == null ? 8 : currencyPairMetaData.getPriceScale(), + currencyPairMetaData == null ? ZERO : currencyPairMetaData.getMinimumAmount()); + } + + @Override + public SimulatedMarketDataService getMarketDataService() { + return (SimulatedMarketDataService) super.getMarketDataService(); + } + + @Override + public SimulatedAccountService getAccountService() { + return (SimulatedAccountService) super.getAccountService(); + } + + @Override + public SimulatedTradeService getTradeService() { + return (SimulatedTradeService) super.getTradeService(); + } +} diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedExchangeOperationListener.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedExchangeOperationListener.java new file mode 100644 index 00000000000..cdb2f4200a1 --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedExchangeOperationListener.java @@ -0,0 +1,24 @@ +package org.knowm.xchange.simulated; + +import java.io.IOException; +import org.knowm.xchange.ExchangeSpecification; + +/** + * Listener which is called every time the {@link SimulatedExchange} performs an operation. + * + *

Pass instances to {@link ExchangeSpecification#getExchangeSpecificParametersItem(String)} + * using the parameter name {@link SimulatedExchange#ON_OPERATION_PARAM} to have them called back. + * + *

See {@link RandomExceptionThrower} for an example implementation. + * + * @author Graham Crockford + */ +public interface SimulatedExchangeOperationListener { + + /** + * Called every time + * + * @throws IOException + */ + void onSimulatedExchangeOperation() throws IOException; +} diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedMarketDataService.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedMarketDataService.java new file mode 100644 index 00000000000..25bb577611f --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedMarketDataService.java @@ -0,0 +1,35 @@ +package org.knowm.xchange.simulated; + +import java.io.IOException; +import org.knowm.xchange.currency.CurrencyPair; +import org.knowm.xchange.dto.marketdata.OrderBook; +import org.knowm.xchange.dto.marketdata.Ticker; +import org.knowm.xchange.dto.marketdata.Trades; +import org.knowm.xchange.service.BaseExchangeService; +import org.knowm.xchange.service.marketdata.MarketDataService; + +public class SimulatedMarketDataService extends BaseExchangeService + implements MarketDataService { + + protected SimulatedMarketDataService(SimulatedExchange exchange) { + super(exchange); + } + + @Override + public Ticker getTicker(CurrencyPair currencyPair, Object... args) throws IOException { + exchange.maybeThrow(); + return exchange.getEngine(currencyPair).ticker(); + } + + @Override + public OrderBook getOrderBook(CurrencyPair currencyPair, Object... args) throws IOException { + exchange.maybeThrow(); + return exchange.getEngine(currencyPair).level2(); + } + + @Override + public Trades getTrades(CurrencyPair currencyPair, Object... args) throws IOException { + exchange.maybeThrow(); + return new Trades(exchange.getEngine(currencyPair).publicTrades()); + } +} diff --git a/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedTradeService.java b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedTradeService.java new file mode 100644 index 00000000000..7a78816e581 --- /dev/null +++ b/xchange-simulated/src/main/java/org/knowm/xchange/simulated/SimulatedTradeService.java @@ -0,0 +1,93 @@ +package org.knowm.xchange.simulated; + +import java.io.IOException; +import org.apache.commons.lang3.StringUtils; +import org.knowm.xchange.dto.marketdata.Trades.TradeSortType; +import org.knowm.xchange.dto.trade.LimitOrder; +import org.knowm.xchange.dto.trade.MarketOrder; +import org.knowm.xchange.dto.trade.OpenOrders; +import org.knowm.xchange.dto.trade.UserTrades; +import org.knowm.xchange.exceptions.ExchangeException; +import org.knowm.xchange.exceptions.ExchangeSecurityException; +import org.knowm.xchange.service.BaseExchangeService; +import org.knowm.xchange.service.trade.TradeService; +import org.knowm.xchange.service.trade.params.DefaultTradeHistoryParamCurrencyPair; +import org.knowm.xchange.service.trade.params.TradeHistoryParamCurrencyPair; +import org.knowm.xchange.service.trade.params.TradeHistoryParams; +import org.knowm.xchange.service.trade.params.orders.DefaultOpenOrdersParamCurrencyPair; +import org.knowm.xchange.service.trade.params.orders.OpenOrdersParamCurrencyPair; +import org.knowm.xchange.service.trade.params.orders.OpenOrdersParams; + +public class SimulatedTradeService extends BaseExchangeService + implements TradeService { + + protected SimulatedTradeService(SimulatedExchange exchange) { + super(exchange); + } + + @Override + public String placeLimitOrder(LimitOrder limitOrder) throws IOException { + MatchingEngine engine = exchange.getEngine(limitOrder.getCurrencyPair()); + exchange.maybeThrow(); + return engine.postOrder(getApiKey(), limitOrder).getId(); + } + + /** + * Use instead of {@link #placeLimitOrder(LimitOrder)} to bypass rate limitations and transient + * errors when building up a simulated order book. + * + * @param limitOrder The limit order. + * @return The id of the resulting order. + */ + public String placeLimitOrderUnrestricted(LimitOrder limitOrder) { + MatchingEngine engine = exchange.getEngine(limitOrder.getCurrencyPair()); + return engine.postOrder(getApiKey(), limitOrder).getId(); + } + + @Override + public String placeMarketOrder(MarketOrder marketOrder) throws IOException { + MatchingEngine engine = exchange.getEngine(marketOrder.getCurrencyPair()); + exchange.maybeThrow(); + return engine.postOrder(getApiKey(), marketOrder).getId(); + } + + @Override + public OpenOrders getOpenOrders(OpenOrdersParams params) throws IOException { + if (!(params instanceof OpenOrdersParamCurrencyPair)) { + throw new ExchangeException("Currency pair required"); + } + MatchingEngine engine = + exchange.getEngine(((OpenOrdersParamCurrencyPair) params).getCurrencyPair()); + exchange.maybeThrow(); + return new OpenOrders(engine.openOrders(getApiKey())); + } + + @Override + public UserTrades getTradeHistory(TradeHistoryParams params) throws IOException { + if (!(params instanceof TradeHistoryParamCurrencyPair)) { + throw new ExchangeException("Currency pair required"); + } + MatchingEngine engine = + exchange.getEngine(((TradeHistoryParamCurrencyPair) params).getCurrencyPair()); + exchange.maybeThrow(); + return new UserTrades(engine.tradeHistory(getApiKey()), TradeSortType.SortByTimestamp); + } + + @Override + public OpenOrdersParamCurrencyPair createOpenOrdersParams() { + return new DefaultOpenOrdersParamCurrencyPair(); + } + + @Override + public TradeHistoryParamCurrencyPair createTradeHistoryParams() { + return new DefaultTradeHistoryParamCurrencyPair(); + } + + private String getApiKey() { + String apiKey = exchange.getExchangeSpecification().getApiKey(); + if (StringUtils.isEmpty(apiKey)) { + throw new ExchangeSecurityException("No API key provided"); + } + return apiKey; + } +} diff --git a/xchange-simulated/src/main/resources/simulated.json b/xchange-simulated/src/main/resources/simulated.json new file mode 100644 index 00000000000..28aa3cc4e81 --- /dev/null +++ b/xchange-simulated/src/main/resources/simulated.json @@ -0,0 +1,25 @@ +{ + "currency_pairs": { + "BTC/USD": { + "price_scale": 2, + "min_amount": 0.0001 + } + }, + "currencies": { + "BTC": { + "scale": 8, + "withdrawal_fee": 0.2 + }, + "USD": { + "scale": 2, + "withdrawal_fee": 0 + } + }, + "public_rate_limits": [ + { + "calls": 5, + "time_span": 1, + "time_unit": "SECONDS" + } + ] +} diff --git a/xchange-simulated/src/test/java/org/knowm/xchange/simulated/AbstractFillAssert.java b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/AbstractFillAssert.java new file mode 100644 index 00000000000..0a5ebf0074d --- /dev/null +++ b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/AbstractFillAssert.java @@ -0,0 +1,107 @@ +package org.knowm.xchange.simulated; + +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.util.Objects; + +/** + * Abstract base class for {@link Fill} specific assertions - Generated by CustomAssertionGenerator. + */ +@javax.annotation.Generated(value = "assertj-assertions-generator") +abstract class AbstractFillAssert, A extends Fill> + extends AbstractObjectAssert { + + /** + * Creates a new {@link AbstractFillAssert} to make assertions on actual Fill. + * + * @param actual the Fill we want to make assertions on. + */ + protected AbstractFillAssert(A actual, Class selfType) { + super(actual, selfType); + } + + /** + * Verifies that the actual Fill's apiKey is equal to the given one. + * + * @param apiKey the given apiKey to compare the actual Fill's apiKey to. + * @return this assertion object. + * @throws AssertionError - if the actual Fill's apiKey is not equal to the given one. + */ + public S hasApiKey(String apiKey) { + // check that actual Fill we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = "\nExpecting apiKey of:\n <%s>\nto be:\n <%s>\nbut was:\n <%s>"; + + // null safe check + String actualApiKey = actual.getApiKey(); + if (!Objects.areEqual(actualApiKey, apiKey)) { + failWithMessage(assertjErrorMessage, actual, apiKey, actualApiKey); + } + + // return the current assertion for method chaining + return myself; + } + + /** + * Verifies that the actual Fill is taker. + * + * @return this assertion object. + * @throws AssertionError - if the actual Fill is not taker. + */ + public S isTaker() { + // check that actual Fill we want to make assertions on is not null. + isNotNull(); + + // check that property call/field access is true + if (!actual.isTaker()) { + failWithMessage("\nExpecting that actual Fill is taker but is not."); + } + + // return the current assertion for method chaining + return myself; + } + + /** + * Verifies that the actual Fill is not taker. + * + * @return this assertion object. + * @throws AssertionError - if the actual Fill is taker. + */ + public S isNotTaker() { + // check that actual Fill we want to make assertions on is not null. + isNotNull(); + + // check that property call/field access is false + if (actual.isTaker()) { + failWithMessage("\nExpecting that actual Fill is not taker but is."); + } + + // return the current assertion for method chaining + return myself; + } + + /** + * Verifies that the actual Fill's trade is equal to the given one. + * + * @param trade the given trade to compare the actual Fill's trade to. + * @return this assertion object. + * @throws AssertionError - if the actual Fill's trade is not equal to the given one. + */ + public S hasTrade(org.knowm.xchange.dto.trade.UserTrade trade) { + // check that actual Fill we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = "\nExpecting trade of:\n <%s>\nto be:\n <%s>\nbut was:\n <%s>"; + + // null safe check + org.knowm.xchange.dto.trade.UserTrade actualTrade = actual.getTrade(); + if (!Objects.areEqual(actualTrade, trade)) { + failWithMessage(assertjErrorMessage, actual, trade, actualTrade); + } + + // return the current assertion for method chaining + return myself; + } +} diff --git a/xchange-simulated/src/test/java/org/knowm/xchange/simulated/AbstractTradeAssert.java b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/AbstractTradeAssert.java new file mode 100644 index 00000000000..c65de64e99d --- /dev/null +++ b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/AbstractTradeAssert.java @@ -0,0 +1,235 @@ +package org.knowm.xchange.simulated; + +import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.util.Objects; +import org.knowm.xchange.dto.marketdata.Trade; + +/** + * Abstract base class for {@link Trade} specific assertions - Generated by + * CustomAssertionGenerator. + */ +@javax.annotation.Generated(value = "assertj-assertions-generator") +abstract class AbstractTradeAssert, A extends Trade> + extends AbstractObjectAssert { + + /** + * Creates a new {@link AbstractTradeAssert} to make assertions on actual Trade. + * + * @param actual the Trade we want to make assertions on. + */ + protected AbstractTradeAssert(A actual, Class selfType) { + super(actual, selfType); + } + + /** + * Verifies that the actual Trade's currencyPair is equal to the given one. + * + * @param currencyPair the given currencyPair to compare the actual Trade's currencyPair to. + * @return this assertion object. + * @throws AssertionError - if the actual Trade's currencyPair is not equal to the given one. + */ + public S hasCurrencyPair(org.knowm.xchange.currency.CurrencyPair currencyPair) { + // check that actual Trade we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = + "\nExpecting currencyPair of:\n <%s>\nto be:\n <%s>\nbut was:\n <%s>"; + + // null safe check + org.knowm.xchange.currency.CurrencyPair actualCurrencyPair = actual.getCurrencyPair(); + if (!Objects.areEqual(actualCurrencyPair, currencyPair)) { + failWithMessage(assertjErrorMessage, actual, currencyPair, actualCurrencyPair); + } + + // return the current assertion for method chaining + return myself; + } + + /** + * Verifies that the actual Trade's id is equal to the given one. + * + * @param id the given id to compare the actual Trade's id to. + * @return this assertion object. + * @throws AssertionError - if the actual Trade's id is not equal to the given one. + */ + public S hasId(String id) { + // check that actual Trade we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = "\nExpecting id of:\n <%s>\nto be:\n <%s>\nbut was:\n <%s>"; + + // null safe check + String actualId = actual.getId(); + if (!Objects.areEqual(actualId, id)) { + failWithMessage(assertjErrorMessage, actual, id, actualId); + } + + // return the current assertion for method chaining + return myself; + } + + public S hasId() { + + // check that actual Trade we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = "\nExpecting id to not be null"; + + if (actual.getId() == null) { + failWithMessage(assertjErrorMessage); + } + + return myself; + } + + /** + * Verifies that the actual Trade's makerOrderId is equal to the given one. + * + * @param makerOrderId the given makerOrderId to compare the actual Trade's makerOrderId to. + * @return this assertion object. + * @throws AssertionError - if the actual Trade's makerOrderId is not equal to the given one. + */ + public S hasMakerOrderId(String makerOrderId) { + // check that actual Trade we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = + "\nExpecting makerOrderId of:\n <%s>\nto be:\n <%s>\nbut was:\n <%s>"; + + // null safe check + String actualMakerOrderId = actual.getMakerOrderId(); + if (!Objects.areEqual(actualMakerOrderId, makerOrderId)) { + failWithMessage(assertjErrorMessage, actual, makerOrderId, actualMakerOrderId); + } + + // return the current assertion for method chaining + return myself; + } + + /** + * Verifies that the actual Trade's originalAmount is equal to the given one. + * + * @param originalAmount the given originalAmount to compare the actual Trade's originalAmount to. + * @return this assertion object. + * @throws AssertionError - if the actual Trade's originalAmount is not equal to the given one. + */ + public S hasOriginalAmount(java.math.BigDecimal originalAmount) { + // check that actual Trade we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = + "\nExpecting originalAmount of:\n <%s>\nto be:\n <%s>\nbut was:\n <%s>"; + + // null safe check + java.math.BigDecimal actualOriginalAmount = actual.getOriginalAmount(); + if (!Objects.areEqual(actualOriginalAmount, originalAmount)) { + failWithMessage(assertjErrorMessage, actual, originalAmount, actualOriginalAmount); + } + + // return the current assertion for method chaining + return myself; + } + + /** + * Verifies that the actual Trade's price is equal to the given one. + * + * @param price the given price to compare the actual Trade's price to. + * @return this assertion object. + * @throws AssertionError - if the actual Trade's price is not equal to the given one. + */ + public S hasPrice(java.math.BigDecimal price) { + // check that actual Trade we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = "\nExpecting price of:\n <%s>\nto be:\n <%s>\nbut was:\n <%s>"; + + // null safe check + java.math.BigDecimal actualPrice = actual.getPrice(); + if (!Objects.areEqual(actualPrice, price)) { + failWithMessage(assertjErrorMessage, actual, price, actualPrice); + } + + // return the current assertion for method chaining + return myself; + } + + /** + * Verifies that the actual Trade's takerOrderId is equal to the given one. + * + * @param takerOrderId the given takerOrderId to compare the actual Trade's takerOrderId to. + * @return this assertion object. + * @throws AssertionError - if the actual Trade's takerOrderId is not equal to the given one. + */ + public S hasTakerOrderId(String takerOrderId) { + // check that actual Trade we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = + "\nExpecting takerOrderId of:\n <%s>\nto be:\n <%s>\nbut was:\n <%s>"; + + // null safe check + String actualTakerOrderId = actual.getTakerOrderId(); + if (!Objects.areEqual(actualTakerOrderId, takerOrderId)) { + failWithMessage(assertjErrorMessage, actual, takerOrderId, actualTakerOrderId); + } + + // return the current assertion for method chaining + return myself; + } + + /** + * Verifies that the actual Trade's timestamp is equal to the given one. + * + * @param timestamp the given timestamp to compare the actual Trade's timestamp to. + * @return this assertion object. + * @throws AssertionError - if the actual Trade's timestamp is not equal to the given one. + */ + public S hasTimestamp(java.util.Date timestamp) { + // check that actual Trade we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = + "\nExpecting timestamp of:\n <%s>\nto be:\n <%s>\nbut was:\n <%s>"; + + // null safe check + java.util.Date actualTimestamp = actual.getTimestamp(); + if (!Objects.areEqual(actualTimestamp, timestamp)) { + failWithMessage(assertjErrorMessage, actual, timestamp, actualTimestamp); + } + + // return the current assertion for method chaining + return myself; + } + + /** + * Verifies that the actual Trade's type is equal to the given one. + * + * @param type the given type to compare the actual Trade's type to. + * @return this assertion object. + * @throws AssertionError - if the actual Trade's type is not equal to the given one. + */ + public S hasType(org.knowm.xchange.dto.Order.OrderType type) { + // check that actual Trade we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = "\nExpecting type of:\n <%s>\nto be:\n <%s>\nbut was:\n <%s>"; + + // null safe check + org.knowm.xchange.dto.Order.OrderType actualType = actual.getType(); + if (!Objects.areEqual(actualType, type)) { + failWithMessage(assertjErrorMessage, actual, type, actualType); + } + + // return the current assertion for method chaining + return myself; + } +} diff --git a/xchange-simulated/src/test/java/org/knowm/xchange/simulated/AbstractUserTradeAssert.java b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/AbstractUserTradeAssert.java new file mode 100644 index 00000000000..e3fa4feb362 --- /dev/null +++ b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/AbstractUserTradeAssert.java @@ -0,0 +1,98 @@ +package org.knowm.xchange.simulated; + +import org.assertj.core.util.Objects; +import org.knowm.xchange.dto.trade.UserTrade; + +/** + * Abstract base class for {@link UserTrade} specific assertions - Generated by + * CustomAssertionGenerator. + */ +@javax.annotation.Generated(value = "assertj-assertions-generator") +abstract class AbstractUserTradeAssert, A extends UserTrade> + extends AbstractTradeAssert { + + /** + * Creates a new {@link AbstractUserTradeAssert} to make assertions on actual + * UserTrade. + * + * @param actual the UserTrade we want to make assertions on. + */ + protected AbstractUserTradeAssert(A actual, Class selfType) { + super(actual, selfType); + } + + /** + * Verifies that the actual UserTrade's feeAmount is equal to the given one. + * + * @param feeAmount the given feeAmount to compare the actual UserTrade's feeAmount to. + * @return this assertion object. + * @throws AssertionError - if the actual UserTrade's feeAmount is not equal to the given one. + */ + public S hasFeeAmount(java.math.BigDecimal feeAmount) { + // check that actual UserTrade we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = + "\nExpecting feeAmount of:\n <%s>\nto be:\n <%s>\nbut was:\n <%s>"; + + // null safe check + java.math.BigDecimal actualFeeAmount = actual.getFeeAmount(); + if (!Objects.areEqual(actualFeeAmount, feeAmount)) { + failWithMessage(assertjErrorMessage, actual, feeAmount, actualFeeAmount); + } + + // return the current assertion for method chaining + return myself; + } + + /** + * Verifies that the actual UserTrade's feeCurrency is equal to the given one. + * + * @param feeCurrency the given feeCurrency to compare the actual UserTrade's feeCurrency to. + * @return this assertion object. + * @throws AssertionError - if the actual UserTrade's feeCurrency is not equal to the given one. + */ + public S hasFeeCurrency(org.knowm.xchange.currency.Currency feeCurrency) { + // check that actual UserTrade we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = + "\nExpecting feeCurrency of:\n <%s>\nto be:\n <%s>\nbut was:\n <%s>"; + + // null safe check + org.knowm.xchange.currency.Currency actualFeeCurrency = actual.getFeeCurrency(); + if (!Objects.areEqual(actualFeeCurrency, feeCurrency)) { + failWithMessage(assertjErrorMessage, actual, feeCurrency, actualFeeCurrency); + } + + // return the current assertion for method chaining + return myself; + } + + /** + * Verifies that the actual UserTrade's orderId is equal to the given one. + * + * @param orderId the given orderId to compare the actual UserTrade's orderId to. + * @return this assertion object. + * @throws AssertionError - if the actual UserTrade's orderId is not equal to the given one. + */ + public S hasOrderId(String orderId) { + // check that actual UserTrade we want to make assertions on is not null. + isNotNull(); + + // overrides the default error message with a more explicit one + String assertjErrorMessage = + "\nExpecting orderId of:\n <%s>\nto be:\n <%s>\nbut was:\n <%s>"; + + // null safe check + String actualOrderId = actual.getOrderId(); + if (!Objects.areEqual(actualOrderId, orderId)) { + failWithMessage(assertjErrorMessage, actual, orderId, actualOrderId); + } + + // return the current assertion for method chaining + return myself; + } +} diff --git a/xchange-simulated/src/test/java/org/knowm/xchange/simulated/Assertions.java b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/Assertions.java new file mode 100644 index 00000000000..2a370b694f2 --- /dev/null +++ b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/Assertions.java @@ -0,0 +1,50 @@ +package org.knowm.xchange.simulated; + +/** + * Entry point for assertions of different data types. Each method in this class is a static factory + * for the type-specific assertion objects. + */ +@javax.annotation.Generated(value = "assertj-assertions-generator") +class Assertions { + + /** + * Creates a new instance of {@link org.knowm.xchange.simulated.TradeAssert}. + * + * @param actual the actual value. + * @return the created assertion object. + */ + @org.assertj.core.util.CheckReturnValue + public static org.knowm.xchange.simulated.TradeAssert assertThat( + org.knowm.xchange.dto.marketdata.Trade actual) { + return new org.knowm.xchange.simulated.TradeAssert(actual); + } + + /** + * Creates a new instance of {@link org.knowm.xchange.simulated.UserTradeAssert}. + * + * @param actual the actual value. + * @return the created assertion object. + */ + @org.assertj.core.util.CheckReturnValue + public static org.knowm.xchange.simulated.UserTradeAssert assertThat( + org.knowm.xchange.dto.trade.UserTrade actual) { + return new org.knowm.xchange.simulated.UserTradeAssert(actual); + } + + /** + * Creates a new instance of {@link org.knowm.xchange.simulated.FillAssert}. + * + * @param actual the actual value. + * @return the created assertion object. + */ + @org.assertj.core.util.CheckReturnValue + public static org.knowm.xchange.simulated.FillAssert assertThat( + org.knowm.xchange.simulated.Fill actual) { + return new org.knowm.xchange.simulated.FillAssert(actual); + } + + /** Creates a new {@link Assertions}. */ + protected Assertions() { + // empty + } +} diff --git a/xchange-simulated/src/test/java/org/knowm/xchange/simulated/FillAssert.java b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/FillAssert.java new file mode 100644 index 00000000000..d6f692d427e --- /dev/null +++ b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/FillAssert.java @@ -0,0 +1,34 @@ +package org.knowm.xchange.simulated; + +/** + * {@link Fill} specific assertions - Generated by CustomAssertionGenerator. + * + *

Although this class is not final to allow Soft assertions proxy, if you wish to extend it, + * extend {@link AbstractFillAssert} instead. + */ +@javax.annotation.Generated(value = "assertj-assertions-generator") +class FillAssert extends AbstractFillAssert { + + /** + * Creates a new {@link FillAssert} to make assertions on actual Fill. + * + * @param actual the Fill we want to make assertions on. + */ + public FillAssert(Fill actual) { + super(actual, FillAssert.class); + } + + /** + * An entry point for FillAssert to follow AssertJ standard assertThat() statements. + *
+ * With a static import, one can write directly: assertThat(myFill) and get specific + * assertion with code completion. + * + * @param actual the Fill we want to make assertions on. + * @return a new {@link FillAssert} + */ + @org.assertj.core.util.CheckReturnValue + public static FillAssert assertThat(Fill actual) { + return new FillAssert(actual); + } +} diff --git a/xchange-simulated/src/test/java/org/knowm/xchange/simulated/MockMarket.java b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/MockMarket.java new file mode 100644 index 00000000000..174b1d614ee --- /dev/null +++ b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/MockMarket.java @@ -0,0 +1,41 @@ +package org.knowm.xchange.simulated; + +import static org.knowm.xchange.currency.CurrencyPair.BTC_USD; +import static org.knowm.xchange.dto.Order.OrderType.ASK; +import static org.knowm.xchange.dto.Order.OrderType.BID; + +import java.io.IOException; +import java.math.BigDecimal; +import org.knowm.xchange.dto.Order.OrderType; +import org.knowm.xchange.dto.trade.LimitOrder; + +class MockMarket { + + static void mockMarket(SimulatedExchange exchange) throws IOException { + placeMMOrder(exchange, ASK, new BigDecimal(10000), new BigDecimal("200")); + placeMMOrder(exchange, ASK, new BigDecimal(100), new BigDecimal("0.1")); + placeMMOrder(exchange, ASK, new BigDecimal(99), new BigDecimal("0.05")); + placeMMOrder(exchange, ASK, new BigDecimal(99), new BigDecimal("0.25")); + placeMMOrder(exchange, ASK, new BigDecimal(98), new BigDecimal("0.3")); + // ---- + placeMMOrder(exchange, BID, new BigDecimal(97), new BigDecimal("0.4")); + placeMMOrder(exchange, BID, new BigDecimal(96), new BigDecimal("0.25")); + placeMMOrder(exchange, BID, new BigDecimal(96), new BigDecimal("0.25")); + placeMMOrder(exchange, BID, new BigDecimal(95), new BigDecimal("0.6")); + placeMMOrder(exchange, BID, new BigDecimal(94), new BigDecimal("0.7")); + placeMMOrder(exchange, BID, new BigDecimal(93), new BigDecimal("0.8")); + placeMMOrder(exchange, BID, new BigDecimal(1), new BigDecimal("1002")); + } + + static void placeMMOrder( + SimulatedExchange exchange, OrderType orderType, BigDecimal price, BigDecimal amount) + throws IOException { + exchange + .getTradeService() + .placeLimitOrderUnrestricted( + new LimitOrder.Builder(orderType, BTC_USD) + .limitPrice(price) + .originalAmount(amount) + .build()); + } +} diff --git a/xchange-simulated/src/test/java/org/knowm/xchange/simulated/SimulatedExchangeExample.java b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/SimulatedExchangeExample.java new file mode 100644 index 00000000000..88bcc3943d7 --- /dev/null +++ b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/SimulatedExchangeExample.java @@ -0,0 +1,138 @@ +package org.knowm.xchange.simulated; + +import static org.knowm.xchange.currency.Currency.BTC; +import static org.knowm.xchange.currency.Currency.USD; +import static org.knowm.xchange.currency.CurrencyPair.BTC_USD; +import static org.knowm.xchange.dto.Order.OrderType.BID; +import static org.knowm.xchange.simulated.SimulatedExchange.ACCOUNT_FACTORY_PARAM; +import static org.knowm.xchange.simulated.SimulatedExchange.ENGINE_FACTORY_PARAM; +import static org.knowm.xchange.simulated.SimulatedExchange.ON_OPERATION_PARAM; + +import com.google.common.util.concurrent.RateLimiter; +import java.io.IOException; +import java.math.BigDecimal; +import org.junit.Test; +import org.knowm.xchange.ExchangeFactory; +import org.knowm.xchange.ExchangeSpecification; +import org.knowm.xchange.dto.trade.MarketOrder; +import org.knowm.xchange.exceptions.NonceException; +import org.knowm.xchange.exceptions.SystemOverloadException; + +public class SimulatedExchangeExample { + + /** Demonstrates the simplest case. */ + @Test + public void simple() throws IOException { + + // If you don't provide an API key you get read-only access. No secret is needed. + ExchangeSpecification exchangeSpecification = + new ExchangeSpecification(SimulatedExchange.class); + exchangeSpecification.setApiKey("Tester"); + SimulatedExchange exchange = + (SimulatedExchange) ExchangeFactory.INSTANCE.createExchange(exchangeSpecification); + + // Provide an initial balance and fill the exchange with orders. By default + // every order book is completely empty. + exchange.getAccountService().deposit(USD, new BigDecimal(10000)); + exchange.getAccountService().deposit(BTC, new BigDecimal(10000)); + MockMarket.mockMarket(exchange); + + // Accounts + System.out.println("Account: " + exchange.getAccountService().getAccountInfo()); + + // Trades + exchange + .getTradeService() + .placeMarketOrder( + new MarketOrder.Builder(BID, BTC_USD).originalAmount(new BigDecimal("0.1")).build()); + + // Market data + System.out.println("Ticker: " + exchange.getMarketDataService().getTicker(BTC_USD)); + System.out.println("Order book: " + exchange.getMarketDataService().getOrderBook(BTC_USD)); + System.out.println("Trades: " + exchange.getMarketDataService().getTrades(BTC_USD)); + } + + /** Demonstrates advanced features. */ + @Test + public void complex() throws IOException { + + // By default, the matching engines are scoped to each instance of the Exchange. This ensures + // that all instances share the same engine within the scope of each test. + AccountFactory accountFactory = new AccountFactory(); + MatchingEngineFactory matchingEngineFactory = new MatchingEngineFactory(accountFactory); + + // Access as a market maker user and use *that* to fill the order books + ExchangeSpecification makerSpec = new ExchangeSpecification(SimulatedExchange.class); + makerSpec.setApiKey("MarketMaker"); + makerSpec.setExchangeSpecificParametersItem(ENGINE_FACTORY_PARAM, matchingEngineFactory); + makerSpec.setExchangeSpecificParametersItem(ACCOUNT_FACTORY_PARAM, accountFactory); + SimulatedExchange makerEx = + (SimulatedExchange) ExchangeFactory.INSTANCE.createExchange(makerSpec); + makerEx.getAccountService().deposit(USD, new BigDecimal(10000)); + makerEx.getAccountService().deposit(BTC, new BigDecimal(10000)); + MockMarket.mockMarket(makerEx); + + // Access as a test user. Add realistic transient errors and rate limitation, which + // we have to handle. + ExchangeSpecification takerSpec = new ExchangeSpecification(SimulatedExchange.class); + takerSpec.setApiKey("Tester"); + takerSpec.setExchangeSpecificParametersItem(ENGINE_FACTORY_PARAM, matchingEngineFactory); + takerSpec.setExchangeSpecificParametersItem(ACCOUNT_FACTORY_PARAM, accountFactory); + takerSpec.setExchangeSpecificParametersItem(ON_OPERATION_PARAM, new RandomExceptionThrower()); + SimulatedExchange takerEx = + (SimulatedExchange) ExchangeFactory.INSTANCE.createExchange(takerSpec); + takerEx.getAccountService().deposit(USD, new BigDecimal(1000)); + + // We can now go ahead and interact with the exchange, but now we are forced to obey best + // practice; we need to obey the rate limit and if we encounter transient exceptions, we + // need to keep trying. + RateLimiter rateLimiter = RateLimiter.create(5); + + // Accounts + retryTransientErrors( + rateLimiter, + () -> System.out.println("Account: " + takerEx.getAccountService().getAccountInfo())); + + // Trades + retryTransientErrors( + rateLimiter, + () -> + takerEx + .getTradeService() + .placeMarketOrder( + new MarketOrder.Builder(BID, BTC_USD) + .originalAmount(new BigDecimal("0.1")) + .build())); + + // Market data + retryTransientErrors( + rateLimiter, + () -> System.out.println("Ticker: " + takerEx.getMarketDataService().getTicker(BTC_USD))); + retryTransientErrors( + rateLimiter, + () -> + System.out.println( + "Order book: " + takerEx.getMarketDataService().getOrderBook(BTC_USD))); + retryTransientErrors( + rateLimiter, + () -> System.out.println("Trades: " + takerEx.getMarketDataService().getTrades(BTC_USD))); + } + + private void retryTransientErrors(RateLimiter rateLimiter, IOExceptionThrowingRunnable runnable) { + while (true) { + try { + rateLimiter.acquire(); + runnable.run(); + break; + } catch (NonceException | SystemOverloadException e) { + System.out.println("Got a transient error (" + e.getMessage() + ")"); + } catch (IOException e) { + System.out.println("Got a socket or I/O error (" + e.getMessage() + ")"); + } + } + } + + private interface IOExceptionThrowingRunnable { + void run() throws IOException; + } +} diff --git a/xchange-simulated/src/test/java/org/knowm/xchange/simulated/TestMatchingEngine.java b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/TestMatchingEngine.java new file mode 100644 index 00000000000..65fb9eec085 --- /dev/null +++ b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/TestMatchingEngine.java @@ -0,0 +1,681 @@ +package org.knowm.xchange.simulated; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.knowm.xchange.currency.Currency.BTC; +import static org.knowm.xchange.currency.Currency.USD; +import static org.knowm.xchange.currency.CurrencyPair.BTC_USD; +import static org.knowm.xchange.dto.Order.OrderStatus.FILLED; +import static org.knowm.xchange.dto.Order.OrderStatus.NEW; +import static org.knowm.xchange.dto.Order.OrderStatus.PARTIALLY_FILLED; +import static org.knowm.xchange.dto.Order.OrderType.ASK; +import static org.knowm.xchange.dto.Order.OrderType.BID; +import static org.knowm.xchange.simulated.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.hamcrest.MockitoHamcrest.argThat; + +import java.math.BigDecimal; +import java.util.function.Consumer; +import org.assertj.core.matcher.AssertionMatcher; +import org.junit.Before; +import org.junit.Test; +import org.knowm.xchange.dto.trade.LimitOrder; +import org.knowm.xchange.dto.trade.MarketOrder; +import org.knowm.xchange.dto.trade.UserTrade; +import org.knowm.xchange.exceptions.ExchangeException; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +public class TestMatchingEngine { + + private static final String MAKER = "MAKER"; + private static final String TAKER = "TAKER"; + @Mock private Consumer onFill; + @Mock private AccountFactory accountFactory; + @Mock private Account account; + private MatchingEngine matchingEngine; + + @Captor private ArgumentCaptor fillCaptor1; + @Captor private ArgumentCaptor fillCaptor2; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + Mockito.when(accountFactory.get(Mockito.anyString())).thenReturn(account); + matchingEngine = + new MatchingEngine(accountFactory, BTC_USD, 2, new BigDecimal("0.001"), onFill); + } + + @Test + public void testValidationOK() { + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal("100.01")) + .originalAmount(new BigDecimal("0.001")) + .build()); + } + + @Test(expected = ExchangeException.class) + public void testValidationNoPriceViolation() { + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(ASK, BTC_USD).originalAmount(new BigDecimal("0.000999999")).build()); + } + + @Test(expected = ExchangeException.class) + public void testValidationMinimumAmountViolation() { + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal("100.01")) + .originalAmount(new BigDecimal("0.000999999")) + .build()); + } + + @Test(expected = ExchangeException.class) + public void testValidationPriceScaleViolation() { + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal("100.011")) + .originalAmount(new BigDecimal("0.001")) + .build()); + } + + @Test(expected = ExchangeException.class) + public void testValidationZeroPriceViolation() { + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal(0)) + .originalAmount(new BigDecimal("0.001")) + .build()); + } + + @Test(expected = ExchangeException.class) + public void testValidationNegativePriceViolation() { + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal("-0.0001")) + .originalAmount(new BigDecimal("0.001")) + .build()); + } + + @Test + public void testAskNoMatch() { + + // Given an empty order book + + // When + LimitOrder result = + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(5)) + .build()); + + // Then + assertThat(result.getId()).isNotNull(); + assertThat(result.getStatus()).isEqualTo(NEW); + verifyZeroInteractions(onFill); + verify(account, never()).fill(any(UserTrade.class), any(Boolean.class)); + verify(account, times(1)).reserve(any(LimitOrder.class)); + verify(account, never()).release(any(LimitOrder.class)); + assertThat(matchingEngine.book().getAsks()).contains(result); + assertThat(matchingEngine.book().getBids()).isEmpty(); + } + + @Test + public void testBidNoMatch() { + // Given an empty order book + + // When + LimitOrder result = + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(BID, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(5)) + .build()); + + // Then + assertThat(result.getId()).isNotNull(); + assertThat(result.getStatus()).isEqualTo(NEW); + verifyZeroInteractions(onFill); + verify(account, never()).fill(any(UserTrade.class), any(Boolean.class)); + verify(account, times(1)).reserve(any(LimitOrder.class)); + verify(account, never()).release(any(LimitOrder.class)); + + Level3OrderBook book = matchingEngine.book(); + assertThat(book.getBids()).contains(result); + assertThat(book.getAsks()).isEmpty(); + } + + @Test(expected = ExchangeException.class) + public void testMarketAskEmptyBook() { + matchingEngine.postOrder( + TAKER, new MarketOrder.Builder(ASK, BTC_USD).originalAmount(new BigDecimal(5)).build()); + } + + @Test(expected = ExchangeException.class) + public void testMarketBidEmptyBook() { + matchingEngine.postOrder( + TAKER, new MarketOrder.Builder(BID, BTC_USD).originalAmount(new BigDecimal(5)).build()); + } + + @Test + public void testSimpleAskMatch() { + + // Given + LimitOrder maker = + matchingEngine.postOrder( + MAKER, + new LimitOrder.Builder(BID, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(5)) + .build()); + + verify(account, never()).fill(any(UserTrade.class), any(Boolean.class)); + verify(account, times(1)).reserve(any(LimitOrder.class)); + verify(account, never()).release(any(LimitOrder.class)); + reset(account); + + // When + LimitOrder taker = + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(5)) + .build()); + + verify(account, times(2)).fill(any(UserTrade.class), any(Boolean.class)); + verify(account, never()).reserve(any(LimitOrder.class)); + verify(account, never()).release(any(LimitOrder.class)); + + // Then + assertThat(taker.getStatus()).isEqualTo(FILLED); + + verify(onFill) + .accept( + argThat( + new AssertionMatcher() { + @Override + public void assertion(Fill actual) throws AssertionError { + assertThat(actual).hasApiKey(TAKER).isTaker(); + assertThat(actual.getTrade()) + .hasOrderId(taker.getId()) + .hasId() + .hasFeeAmount(new BigDecimal("0.500")) + .hasFeeCurrency(USD) + .hasOriginalAmount(new BigDecimal(5)) + .hasPrice(new BigDecimal(100)) + .hasType(ASK); + } + })); + + verify(onFill) + .accept( + argThat( + new AssertionMatcher() { + @Override + public void assertion(Fill actual) throws AssertionError { + assertThat(actual).hasApiKey(MAKER).isNotTaker(); + assertThat(actual.getTrade()) + .hasOrderId(maker.getId()) + .hasId() + .hasFeeAmount(new BigDecimal("0.005")) + .hasFeeCurrency(BTC) + .hasOriginalAmount(new BigDecimal(5)) + .hasPrice(new BigDecimal(100)) + .hasType(BID); + } + })); + + verifyNoMoreInteractions(onFill); + + Level3OrderBook book = matchingEngine.book(); + assertThat(book.getBids()).isEmpty(); + assertThat(book.getAsks()).isEmpty(); + } + + @Test + public void testSimpleBidMatch() { + + // Given + LimitOrder maker = + matchingEngine.postOrder( + MAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(5)) + .build()); + + verify(account, never()).fill(any(UserTrade.class), any(Boolean.class)); + verify(account, times(1)).reserve(any(LimitOrder.class)); + verify(account, never()).release(any(LimitOrder.class)); + reset(account); + + // When + LimitOrder taker = + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(BID, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(5)) + .build()); + + verify(account, times(2)).fill(any(UserTrade.class), any(Boolean.class)); + verify(account, never()).reserve(any(LimitOrder.class)); + verify(account, never()).release(any(LimitOrder.class)); + + // Then + assertThat(taker.getStatus()).isEqualTo(FILLED); + + verify(onFill) + .accept( + argThat( + new AssertionMatcher() { + @Override + public void assertion(Fill actual) throws AssertionError { + assertThat(actual).hasApiKey(TAKER).isTaker(); + assertThat(actual.getTrade()) + .hasOrderId(taker.getId()) + .hasId() + .hasFeeAmount(new BigDecimal("0.005")) + .hasFeeCurrency(BTC) + .hasOriginalAmount(new BigDecimal(5)) + .hasPrice(new BigDecimal(100)) + .hasType(BID); + } + })); + + verify(onFill) + .accept( + argThat( + new AssertionMatcher() { + @Override + public void assertion(Fill actual) throws AssertionError { + assertThat(actual).hasApiKey(MAKER).isNotTaker(); + assertThat(actual.getTrade()) + .hasOrderId(maker.getId()) + .hasId() + .hasFeeAmount(new BigDecimal("0.500")) + .hasFeeCurrency(USD) + .hasOriginalAmount(new BigDecimal(5)) + .hasPrice(new BigDecimal(100)) + .hasType(ASK); + } + })); + + verifyNoMoreInteractions(onFill); + + Level3OrderBook book = matchingEngine.book(); + assertThat(book.getBids()).isEmpty(); + assertThat(book.getAsks()).isEmpty(); + } + + @Test + public void testSimpleAskPartial() { + + // Given + LimitOrder maker = + matchingEngine.postOrder( + MAKER, + new LimitOrder.Builder(BID, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(5)) + .build()); + + // When + LimitOrder taker = + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(7)) + .build()); + + // Then + assertThat(taker.getStatus()).isEqualTo(PARTIALLY_FILLED); + + verify(onFill).accept(argThat(useAmount(TAKER, taker, new BigDecimal(5)))); + verify(onFill).accept(argThat(useAmount(MAKER, maker, new BigDecimal(5)))); + verifyNoMoreInteractions(onFill); + + Level3OrderBook book = matchingEngine.book(); + assertThat(book.getBids()).isEmpty(); + assertThat(book.getAsks()).hasSize(1); + assertThat(book.getAsks().get(0).getCumulativeAmount()).isEqualTo(new BigDecimal(5)); + } + + @Test + public void testSimpleBidPartial() { + + // Given + LimitOrder maker = + matchingEngine.postOrder( + MAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(5)) + .build()); + + // When + LimitOrder taker = + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(BID, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(7)) + .build()); + + // Then + assertThat(taker.getStatus()).isEqualTo(PARTIALLY_FILLED); + + verify(onFill).accept(argThat(useAmount(TAKER, taker, new BigDecimal(5)))); + verify(onFill).accept(argThat(useAmount(MAKER, maker, new BigDecimal(5)))); + verifyNoMoreInteractions(onFill); + + Level3OrderBook book = matchingEngine.book(); + assertThat(book.getBids()).hasSize(1); + assertThat(book.getAsks()).isEmpty(); + assertThat(book.getBids().get(0).getCumulativeAmount()).isEqualTo(new BigDecimal(5)); + } + + @SuppressWarnings("unchecked") + @Test + public void testAskMultiple() { + + // Given + LimitOrder maker1 = + matchingEngine.postOrder( + MAKER, + new LimitOrder.Builder(BID, BTC_USD) + .limitPrice(new BigDecimal(102)) + .originalAmount(new BigDecimal(1)) + .build()); + LimitOrder maker2 = + matchingEngine.postOrder( + MAKER, + new LimitOrder.Builder(BID, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(2)) + .build()); + LimitOrder maker3 = + matchingEngine.postOrder( + MAKER, + new LimitOrder.Builder(BID, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(4)) + .build()); + LimitOrder maker4 = + matchingEngine.postOrder( + MAKER, + new LimitOrder.Builder(BID, BTC_USD) + .limitPrice(new BigDecimal(101)) + .originalAmount(new BigDecimal(8)) + .build()); + + // When + LimitOrder taker1 = + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(10)) + .build()); + + // Then + assertThat(taker1.getStatus()).isEqualTo(FILLED); + + verify(onFill, atLeastOnce()).accept(fillCaptor1.capture()); + assertThat(fillCaptor1.getAllValues()).hasSize(6); + assertFill( + fillCaptor1.getAllValues().get(0), + TAKER, + taker1, + maker1.getOriginalAmount(), + maker1.getLimitPrice()); + assertFill( + fillCaptor1.getAllValues().get(1), + MAKER, + maker1, + maker1.getOriginalAmount(), + maker1.getLimitPrice()); + assertFill( + fillCaptor1.getAllValues().get(2), + TAKER, + taker1, + maker4.getOriginalAmount(), + maker4.getLimitPrice()); + assertFill( + fillCaptor1.getAllValues().get(3), + MAKER, + maker4, + maker4.getOriginalAmount(), + maker4.getLimitPrice()); + assertFill( + fillCaptor1.getAllValues().get(4), + TAKER, + taker1, + new BigDecimal(1), + maker2.getLimitPrice()); + assertFill( + fillCaptor1.getAllValues().get(5), + MAKER, + maker2, + new BigDecimal(1), + maker2.getLimitPrice()); + + verifyNoMoreInteractions(onFill); + reset(onFill); + + // When + LimitOrder taker2 = + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(5)) + .build()); + + // Then + assertThat(taker2.getStatus()).isEqualTo(FILLED); + + verify(onFill, atLeastOnce()).accept(fillCaptor2.capture()); + assertThat(fillCaptor2.getAllValues()).hasSize(4); + assertFill( + fillCaptor2.getAllValues().get(0), + TAKER, + taker2, + new BigDecimal(1), + maker2.getLimitPrice()); + assertFill( + fillCaptor2.getAllValues().get(1), + MAKER, + maker2, + new BigDecimal(1), + maker2.getLimitPrice()); + assertFill( + fillCaptor2.getAllValues().get(2), + TAKER, + taker2, + new BigDecimal(4), + maker3.getLimitPrice()); + assertFill( + fillCaptor2.getAllValues().get(3), + MAKER, + maker3, + new BigDecimal(4), + maker3.getLimitPrice()); + + Level3OrderBook book = matchingEngine.book(); + assertThat(book.getBids()).isEmpty(); + assertThat(book.getAsks()).isEmpty(); + } + + @SuppressWarnings("unchecked") + @Test + public void testBidMultiple() { + + // Given + LimitOrder maker1 = + matchingEngine.postOrder( + MAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal(100)) + .originalAmount(new BigDecimal(1)) + .build()); + LimitOrder maker2 = + matchingEngine.postOrder( + MAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal(102)) + .originalAmount(new BigDecimal(2)) + .build()); + LimitOrder maker3 = + matchingEngine.postOrder( + MAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal(102)) + .originalAmount(new BigDecimal(4)) + .build()); + LimitOrder maker4 = + matchingEngine.postOrder( + MAKER, + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal(101)) + .originalAmount(new BigDecimal(8)) + .build()); + + // When + LimitOrder taker1 = + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(BID, BTC_USD) + .limitPrice(new BigDecimal(102)) + .originalAmount(new BigDecimal(10)) + .build()); + + // Then + assertThat(taker1.getStatus()).isEqualTo(FILLED); + + verify(onFill, atLeastOnce()).accept(fillCaptor1.capture()); + assertThat(fillCaptor1.getAllValues()).hasSize(6); + assertFill( + fillCaptor1.getAllValues().get(0), + TAKER, + taker1, + maker1.getOriginalAmount(), + maker1.getLimitPrice()); + assertFill( + fillCaptor1.getAllValues().get(1), + MAKER, + maker1, + maker1.getOriginalAmount(), + maker1.getLimitPrice()); + assertFill( + fillCaptor1.getAllValues().get(2), + TAKER, + taker1, + maker4.getOriginalAmount(), + maker4.getLimitPrice()); + assertFill( + fillCaptor1.getAllValues().get(3), + MAKER, + maker4, + maker4.getOriginalAmount(), + maker4.getLimitPrice()); + assertFill( + fillCaptor1.getAllValues().get(4), + TAKER, + taker1, + new BigDecimal(1), + maker2.getLimitPrice()); + assertFill( + fillCaptor1.getAllValues().get(5), + MAKER, + maker2, + new BigDecimal(1), + maker2.getLimitPrice()); + + verifyNoMoreInteractions(onFill); + reset(onFill); + + // When + LimitOrder taker2 = + matchingEngine.postOrder( + TAKER, + new LimitOrder.Builder(BID, BTC_USD) + .limitPrice(new BigDecimal(102)) + .originalAmount(new BigDecimal(5)) + .build()); + + // Then + assertThat(taker2.getStatus()).isEqualTo(FILLED); + + verify(onFill, atLeastOnce()).accept(fillCaptor2.capture()); + assertThat(fillCaptor2.getAllValues()).hasSize(4); + assertFill( + fillCaptor2.getAllValues().get(0), + TAKER, + taker2, + new BigDecimal(1), + maker2.getLimitPrice()); + assertFill( + fillCaptor2.getAllValues().get(1), + MAKER, + maker2, + new BigDecimal(1), + maker2.getLimitPrice()); + assertFill( + fillCaptor2.getAllValues().get(2), + TAKER, + taker2, + new BigDecimal(4), + maker3.getLimitPrice()); + assertFill( + fillCaptor2.getAllValues().get(3), + MAKER, + maker3, + new BigDecimal(4), + maker3.getLimitPrice()); + + Level3OrderBook book = matchingEngine.book(); + assertThat(book.getBids()).isEmpty(); + assertThat(book.getAsks()).isEmpty(); + } + + private AssertionMatcher useAmount(String apiKey, LimitOrder order, BigDecimal amount) { + return new AssertionMatcher() { + @Override + public void assertion(Fill actual) throws AssertionError { + assertFill(actual, apiKey, order, amount, order.getLimitPrice()); + } + }; + } + + private void assertFill( + Fill fill, String apiKey, LimitOrder order, BigDecimal amount, BigDecimal price) { + assertThat(fill).hasApiKey(apiKey); + assertThat(fill.getTrade()) + .hasOrderId(order.getId()) + .hasId() + .hasOriginalAmount(amount) + .hasPrice(price) + .hasType(order.getType()); + } +} diff --git a/xchange-simulated/src/test/java/org/knowm/xchange/simulated/TestSimulatedExchange.java b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/TestSimulatedExchange.java new file mode 100644 index 00000000000..0211c401c7f --- /dev/null +++ b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/TestSimulatedExchange.java @@ -0,0 +1,369 @@ +package org.knowm.xchange.simulated; + +import static java.math.BigDecimal.ZERO; +import static org.assertj.core.api.Assertions.assertThat; +import static org.knowm.xchange.currency.Currency.BTC; +import static org.knowm.xchange.currency.Currency.USD; +import static org.knowm.xchange.currency.CurrencyPair.BTC_USD; +import static org.knowm.xchange.dto.Order.OrderStatus.NEW; +import static org.knowm.xchange.dto.Order.OrderStatus.PARTIALLY_FILLED; +import static org.knowm.xchange.dto.Order.OrderType.ASK; +import static org.knowm.xchange.dto.Order.OrderType.BID; +import static org.knowm.xchange.simulated.SimulatedExchange.ACCOUNT_FACTORY_PARAM; +import static org.knowm.xchange.simulated.SimulatedExchange.ENGINE_FACTORY_PARAM; + +import java.io.IOException; +import java.math.BigDecimal; +import org.junit.Before; +import org.junit.Test; +import org.knowm.xchange.Exchange; +import org.knowm.xchange.ExchangeFactory; +import org.knowm.xchange.ExchangeSpecification; +import org.knowm.xchange.dto.Order; +import org.knowm.xchange.dto.account.Balance; +import org.knowm.xchange.dto.marketdata.OrderBook; +import org.knowm.xchange.dto.marketdata.Ticker; +import org.knowm.xchange.dto.trade.LimitOrder; +import org.knowm.xchange.dto.trade.MarketOrder; +import org.knowm.xchange.dto.trade.OpenOrders; +import org.knowm.xchange.dto.trade.UserTrades; +import org.knowm.xchange.exceptions.ExchangeException; +import org.knowm.xchange.exceptions.FundsExceededException; +import org.knowm.xchange.service.trade.params.TradeHistoryParamCurrencyPair; +import org.knowm.xchange.service.trade.params.orders.OpenOrdersParamCurrencyPair; + +public class TestSimulatedExchange { + + private static final BigDecimal INITIAL_BALANCE = new BigDecimal(1000); + + private SimulatedExchange exchange; + private MatchingEngineFactory matchingEngineFactory; + private AccountFactory accountFactory; + + @Before + public void setup() throws IOException { + + // By default, the matching engines are scoped to each instance of the Exchange. This ensures + // that all instances share the same engine within the scope of each test. + accountFactory = new AccountFactory(); + matchingEngineFactory = new MatchingEngineFactory(accountFactory); + + // As a market maker, fill the order book with buy/sell orders + mockMarket(); + + // This is what we'll use for trade testing + ExchangeSpecification exchangeSpecification = + new ExchangeSpecification(SimulatedExchange.class); + exchangeSpecification.setApiKey("Tester"); + exchangeSpecification.setExchangeSpecificParametersItem( + ENGINE_FACTORY_PARAM, matchingEngineFactory); + exchangeSpecification.setExchangeSpecificParametersItem(ACCOUNT_FACTORY_PARAM, accountFactory); + exchange = (SimulatedExchange) ExchangeFactory.INSTANCE.createExchange(exchangeSpecification); + + // Provide an initial balance + exchange.getAccountService().deposit(USD, INITIAL_BALANCE); + exchange.getAccountService().deposit(BTC, INITIAL_BALANCE); + } + + @Test + public void testStartPosition() throws IOException { + // When + OrderBook orderBook = exchange.getMarketDataService().getOrderBook(BTC_USD); + Ticker ticker = exchange.getMarketDataService().getTicker(BTC_USD); + Balance baseBalance = exchange.getAccountService().getAccountInfo().getWallet().getBalance(BTC); + Balance counterBalance = + exchange.getAccountService().getAccountInfo().getWallet().getBalance(USD); + + // Then + + assertThat(orderBook.getAsks()).hasSize(4); + assertThat(orderBook.getAsks().get(0).getLimitPrice()).isEqualTo(new BigDecimal(98)); + assertThat(orderBook.getBids()).hasSize(6); + assertThat(orderBook.getBids().get(0).getLimitPrice()).isEqualTo(new BigDecimal(97)); + assertThat(ticker.getAsk()).isEqualTo(new BigDecimal(98)); + assertThat(ticker.getBid()).isEqualTo(new BigDecimal(97)); + assertThat(ticker.getLast()).isNull(); + assertThat(getOpenOrders().getAllOpenOrders()).isEmpty(); + assertThat(getTradeHistory(exchange).getTrades()).isEmpty(); + assertThat(baseBalance.getAvailable()).isEqualTo(INITIAL_BALANCE); + assertThat(baseBalance.getTotal()).isEqualTo(INITIAL_BALANCE); + assertThat(baseBalance.getFrozen()).isEqualTo(ZERO); + assertThat(counterBalance.getAvailable()).isEqualTo(INITIAL_BALANCE); + assertThat(counterBalance.getTotal()).isEqualTo(INITIAL_BALANCE); + assertThat(counterBalance.getFrozen()).isEqualTo(ZERO); + } + + @Test(expected = ExchangeException.class) + public void testInsufficientLiquidityBid() throws IOException { + exchange + .getTradeService() + .placeMarketOrder( + new MarketOrder.Builder(BID, BTC_USD).originalAmount(new BigDecimal("250")).build()); + } + + @Test(expected = ExchangeException.class) + public void testInsufficientLiquidityAsk() throws IOException { + exchange + .getTradeService() + .placeMarketOrder( + new MarketOrder.Builder(ASK, BTC_USD).originalAmount(new BigDecimal("1002.1")).build()); + } + + @Test(expected = FundsExceededException.class) + public void testInsufficientFundsBid() throws IOException { + exchange + .getTradeService() + .placeMarketOrder( + new MarketOrder.Builder(BID, BTC_USD).originalAmount(new BigDecimal("150")).build()); + } + + @Test(expected = FundsExceededException.class) + public void testInsufficientFundsAsk() throws IOException { + exchange + .getTradeService() + .placeMarketOrder( + new MarketOrder.Builder(ASK, BTC_USD) + .originalAmount(new BigDecimal("1000.01")) + .build()); + } + + @Test + public void testTradeHistoryIsolation() throws IOException { + + // Given + ExchangeSpecification exchangeSpecification = + new ExchangeSpecification(SimulatedExchange.class); + exchangeSpecification.setApiKey("SomeoneElse"); + exchangeSpecification.setExchangeSpecificParametersItem( + ENGINE_FACTORY_PARAM, matchingEngineFactory); + Exchange someoneElsesExchange = ExchangeFactory.INSTANCE.createExchange(exchangeSpecification); + + // When + exchange + .getTradeService() + .placeMarketOrder( + new MarketOrder.Builder(ASK, BTC_USD).originalAmount(new BigDecimal("0.7")).build()); + + // Then + assertThat(exchange.getMarketDataService().getTrades(BTC_USD).getTrades()).hasSize(3); + assertThat(someoneElsesExchange.getMarketDataService().getTrades(BTC_USD).getTrades()) + .hasSize(3); + assertThat(getTradeHistory(exchange).getTrades()).hasSize(3); + assertThat(getTradeHistory(someoneElsesExchange).getTrades()).isEmpty(); + } + + @Test + public void testTradingMarketAsk() throws IOException { + + // When + exchange + .getTradeService() + .placeMarketOrder( + new MarketOrder.Builder(ASK, BTC_USD).originalAmount(new BigDecimal("0.7")).build()); + OrderBook orderBook = exchange.getMarketDataService().getOrderBook(BTC_USD); + Ticker ticker = exchange.getMarketDataService().getTicker(BTC_USD); + Balance baseBalance = exchange.getAccountService().getAccountInfo().getWallet().getBalance(BTC); + Balance counterBalance = + exchange.getAccountService().getAccountInfo().getWallet().getBalance(USD); + + // Then + assertThat(orderBook.getAsks()).hasSize(4); + assertThat(orderBook.getBids()).hasSize(5); + assertThat(ticker.getAsk()).isEqualTo(new BigDecimal(98)); + assertThat(ticker.getBid()).isEqualTo(new BigDecimal(96)); + assertThat(ticker.getLast()).isEqualTo(new BigDecimal(96)); + assertThat(getTradeHistory(exchange).getTrades()).hasSize(3); + + BigDecimal expectedUsdProceeds = + new BigDecimal(97) + .multiply(new BigDecimal("0.40")) + .add(new BigDecimal(96).multiply(new BigDecimal("0.30"))); + assertThat(baseBalance.getAvailable()) + .isEqualTo(INITIAL_BALANCE.subtract(new BigDecimal("0.70"))); + assertThat(baseBalance.getTotal()).isEqualTo(INITIAL_BALANCE.subtract(new BigDecimal("0.70"))); + assertThat(baseBalance.getFrozen()).isEqualTo(ZERO); + assertThat(counterBalance.getAvailable()).isEqualTo(INITIAL_BALANCE.add(expectedUsdProceeds)); + assertThat(counterBalance.getTotal()).isEqualTo(INITIAL_BALANCE.add(expectedUsdProceeds)); + assertThat(counterBalance.getFrozen()).isEqualTo(ZERO); + } + + @Test + public void testTradingLimitAsk() throws IOException { + + // When + String orderId = + exchange + .getTradeService() + .placeLimitOrder( + new LimitOrder.Builder(ASK, BTC_USD) + .limitPrice(new BigDecimal(97)) + .originalAmount(new BigDecimal("0.7")) + .build()); + OrderBook orderBook = exchange.getMarketDataService().getOrderBook(BTC_USD); + Ticker ticker = exchange.getMarketDataService().getTicker(BTC_USD); + Balance baseBalance = exchange.getAccountService().getAccountInfo().getWallet().getBalance(BTC); + Balance counterBalance = + exchange.getAccountService().getAccountInfo().getWallet().getBalance(USD); + + // THen + assertThat(orderBook.getAsks()).hasSize(5); + assertThat(orderBook.getBids()).hasSize(5); + assertThat(ticker.getAsk()).isEqualTo(new BigDecimal(97)); + assertThat(ticker.getBid()).isEqualTo(new BigDecimal(96)); + assertThat(ticker.getLast()).isEqualTo(new BigDecimal(97)); + + OpenOrders orders = getOpenOrders(); + assertThat(orders.getOpenOrders()).hasSize(1); + assertThat(orders.getOpenOrders().get(0).getRemainingAmount()).isEqualTo(new BigDecimal("0.3")); + assertThat(orders.getOpenOrders().get(0).getCumulativeAmount()) + .isEqualTo(new BigDecimal("0.4")); + assertThat(orders.getOpenOrders().get(0).getAveragePrice()).isEqualTo(new BigDecimal(97)); + assertThat(orders.getOpenOrders().get(0).getId()).isEqualTo(orderId); + assertThat(orders.getOpenOrders().get(0).getStatus()).isEqualTo(PARTIALLY_FILLED); + + assertThat(getTradeHistory(exchange).getTrades()).hasSize(1); + + BigDecimal expectedUsdProceeds = new BigDecimal(97).multiply(new BigDecimal("0.4")); + assertThat(baseBalance.getTotal()).isEqualTo(INITIAL_BALANCE.subtract(new BigDecimal("0.4"))); + assertThat(baseBalance.getFrozen()).isEqualTo(new BigDecimal("0.3")); + assertThat(baseBalance.getAvailable()) + .isEqualTo(INITIAL_BALANCE.subtract(new BigDecimal("0.7"))); + assertThat(counterBalance.getTotal()).isEqualTo(INITIAL_BALANCE.add(expectedUsdProceeds)); + assertThat(counterBalance.getFrozen()).isEqualTo(ZERO); + assertThat(counterBalance.getAvailable()).isEqualTo(INITIAL_BALANCE.add(expectedUsdProceeds)); + } + + @Test + public void testTradingMarketBid() throws IOException { + + // When + exchange + .getTradeService() + .placeMarketOrder( + new MarketOrder.Builder(BID, BTC_USD).originalAmount(new BigDecimal("0.56")).build()); + OrderBook orderBook = exchange.getMarketDataService().getOrderBook(BTC_USD); + Ticker ticker = exchange.getMarketDataService().getTicker(BTC_USD); + Balance baseBalance = exchange.getAccountService().getAccountInfo().getWallet().getBalance(BTC); + Balance counterBalance = + exchange.getAccountService().getAccountInfo().getWallet().getBalance(USD); + + // THen + assertThat(orderBook.getAsks()).hasSize(3); + assertThat(orderBook.getBids()).hasSize(6); + assertThat(ticker.getAsk()).isEqualTo(new BigDecimal(99)); + assertThat(ticker.getBid()).isEqualTo(new BigDecimal(97)); + assertThat(ticker.getLast()).isEqualTo(new BigDecimal(99)); + assertThat(getTradeHistory(exchange).getTrades()).hasSize(3); + + BigDecimal expectedUsdCost = + new BigDecimal(98) + .multiply(new BigDecimal("0.3")) + .add(new BigDecimal(99).multiply(new BigDecimal("0.26"))); + assertThat(baseBalance.getAvailable()).isEqualTo(INITIAL_BALANCE.add(new BigDecimal("0.56"))); + assertThat(baseBalance.getTotal()).isEqualTo(INITIAL_BALANCE.add(new BigDecimal("0.56"))); + assertThat(baseBalance.getFrozen()).isEqualTo(ZERO); + assertThat(counterBalance.getAvailable()).isEqualTo(INITIAL_BALANCE.subtract(expectedUsdCost)); + assertThat(counterBalance.getTotal()).isEqualTo(INITIAL_BALANCE.subtract(expectedUsdCost)); + assertThat(counterBalance.getFrozen()).isEqualTo(ZERO); + } + + @Test + public void testTradingLimitBid() throws IOException { + + // When + String orderId1 = + exchange + .getTradeService() + .placeLimitOrder( + new LimitOrder.Builder(BID, BTC_USD) + .limitPrice(new BigDecimal(99)) + .originalAmount(new BigDecimal("0.7")) + .build()); + String orderId2 = + exchange + .getTradeService() + .placeLimitOrder( + new LimitOrder.Builder(BID, BTC_USD) + .limitPrice(new BigDecimal(90)) + .originalAmount(new BigDecimal("1")) + .build()); + OrderBook orderBook = exchange.getMarketDataService().getOrderBook(BTC_USD); + Ticker ticker = exchange.getMarketDataService().getTicker(BTC_USD); + Balance baseBalance = exchange.getAccountService().getAccountInfo().getWallet().getBalance(BTC); + Balance counterBalance = + exchange.getAccountService().getAccountInfo().getWallet().getBalance(USD); + + // THen + assertThat(orderBook.getAsks()).hasSize(2); + assertThat(orderBook.getBids()).hasSize(8); + assertThat(ticker.getAsk()).isEqualTo(new BigDecimal(100)); + assertThat(ticker.getBid()).isEqualTo(new BigDecimal(99)); + assertThat(ticker.getLast()).isEqualTo(new BigDecimal(99)); + + OpenOrders orders = getOpenOrders(); + assertThat(orders.getOpenOrders()).hasSize(2); + Order order1 = + orders.getAllOpenOrders().stream() + .filter(o -> o.getId().equals(orderId1)) + .findFirst() + .get(); + Order order2 = + orders.getAllOpenOrders().stream() + .filter(o -> o.getId().equals(orderId2)) + .findFirst() + .get(); + assertThat(order1.getRemainingAmount()).isEqualTo(new BigDecimal("0.10")); + assertThat(order1.getCumulativeAmount()).isEqualTo(new BigDecimal("0.60")); + assertThat(order1.getAveragePrice()).isEqualTo(new BigDecimal("98.50")); + assertThat(order1.getStatus()).isEqualTo(PARTIALLY_FILLED); + assertThat(order2.getRemainingAmount()).isEqualTo(new BigDecimal(1)); + assertThat(order2.getCumulativeAmount()).isEqualTo(ZERO); + assertThat(order2.getAveragePrice()).isNull(); + assertThat(order2.getStatus()).isEqualTo(NEW); + + assertThat(getTradeHistory(exchange).getTrades()).hasSize(3); + + BigDecimal expectedUsdCost = + new BigDecimal(98) + .multiply(new BigDecimal("0.30")) + .add(new BigDecimal(99).multiply(new BigDecimal("0.30"))); + BigDecimal expectedUsdReserved = + new BigDecimal(99) + .multiply(new BigDecimal("0.10")) + .add(new BigDecimal(90).multiply(new BigDecimal(1))); + assertThat(baseBalance.getTotal()).isEqualTo(INITIAL_BALANCE.add(new BigDecimal("0.60"))); + assertThat(baseBalance.getFrozen()).isEqualTo(ZERO); + assertThat(baseBalance.getAvailable()).isEqualTo(INITIAL_BALANCE.add(new BigDecimal("0.60"))); + assertThat(counterBalance.getTotal()).isEqualTo(INITIAL_BALANCE.subtract(expectedUsdCost)); + assertThat(counterBalance.getFrozen()).isEqualTo(expectedUsdReserved); + assertThat(counterBalance.getAvailable()) + .isEqualTo(INITIAL_BALANCE.subtract(expectedUsdCost).subtract(expectedUsdReserved)); + } + + private OpenOrders getOpenOrders() throws IOException { + OpenOrdersParamCurrencyPair params = exchange.getTradeService().createOpenOrdersParams(); + params.setCurrencyPair(BTC_USD); + return exchange.getTradeService().getOpenOrders(params); + } + + private UserTrades getTradeHistory(Exchange exchangeToUse) throws IOException { + TradeHistoryParamCurrencyPair params = + (TradeHistoryParamCurrencyPair) exchangeToUse.getTradeService().createTradeHistoryParams(); + params.setCurrencyPair(BTC_USD); + return exchangeToUse.getTradeService().getTradeHistory(params); + } + + private void mockMarket() throws IOException { + ExchangeSpecification exchangeSpecification = + new ExchangeSpecification(SimulatedExchange.class); + exchangeSpecification.setApiKey("MarketMakers"); + exchangeSpecification.setExchangeSpecificParametersItem( + ENGINE_FACTORY_PARAM, matchingEngineFactory); + exchangeSpecification.setExchangeSpecificParametersItem(ACCOUNT_FACTORY_PARAM, accountFactory); + SimulatedExchange marketMakerExchange = + (SimulatedExchange) ExchangeFactory.INSTANCE.createExchange(exchangeSpecification); + marketMakerExchange.getAccountService().deposit(USD, new BigDecimal(10000)); + marketMakerExchange.getAccountService().deposit(BTC, new BigDecimal(10000)); + MockMarket.mockMarket(marketMakerExchange); + } +} diff --git a/xchange-simulated/src/test/java/org/knowm/xchange/simulated/TradeAssert.java b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/TradeAssert.java new file mode 100644 index 00000000000..663c18116e6 --- /dev/null +++ b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/TradeAssert.java @@ -0,0 +1,36 @@ +package org.knowm.xchange.simulated; + +import org.knowm.xchange.dto.marketdata.Trade; + +/** + * {@link Trade} specific assertions - Generated by CustomAssertionGenerator. + * + *

Although this class is not final to allow Soft assertions proxy, if you wish to extend it, + * extend {@link AbstractTradeAssert} instead. + */ +@javax.annotation.Generated(value = "assertj-assertions-generator") +class TradeAssert extends AbstractTradeAssert { + + /** + * Creates a new {@link TradeAssert} to make assertions on actual Trade. + * + * @param actual the Trade we want to make assertions on. + */ + public TradeAssert(Trade actual) { + super(actual, TradeAssert.class); + } + + /** + * An entry point for TradeAssert to follow AssertJ standard assertThat() statements. + *
+ * With a static import, one can write directly: assertThat(myTrade) and get specific + * assertion with code completion. + * + * @param actual the Trade we want to make assertions on. + * @return a new {@link TradeAssert} + */ + @org.assertj.core.util.CheckReturnValue + public static TradeAssert assertThat(Trade actual) { + return new TradeAssert(actual); + } +} diff --git a/xchange-simulated/src/test/java/org/knowm/xchange/simulated/UserTradeAssert.java b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/UserTradeAssert.java new file mode 100644 index 00000000000..08e0463b3f8 --- /dev/null +++ b/xchange-simulated/src/test/java/org/knowm/xchange/simulated/UserTradeAssert.java @@ -0,0 +1,36 @@ +package org.knowm.xchange.simulated; + +import org.knowm.xchange.dto.trade.UserTrade; + +/** + * {@link UserTrade} specific assertions - Generated by CustomAssertionGenerator. + * + *

Although this class is not final to allow Soft assertions proxy, if you wish to extend it, + * extend {@link AbstractUserTradeAssert} instead. + */ +@javax.annotation.Generated(value = "assertj-assertions-generator") +class UserTradeAssert extends AbstractUserTradeAssert { + + /** + * Creates a new {@link UserTradeAssert} to make assertions on actual UserTrade. + * + * @param actual the UserTrade we want to make assertions on. + */ + public UserTradeAssert(UserTrade actual) { + super(actual, UserTradeAssert.class); + } + + /** + * An entry point for UserTradeAssert to follow AssertJ standard assertThat() + * statements.
+ * With a static import, one can write directly: assertThat(myUserTrade) and get + * specific assertion with code completion. + * + * @param actual the UserTrade we want to make assertions on. + * @return a new {@link UserTradeAssert} + */ + @org.assertj.core.util.CheckReturnValue + public static UserTradeAssert assertThat(UserTrade actual) { + return new UserTradeAssert(actual); + } +}