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);
+ }
+}