package com.after_sunrise.cryptocurrency.cryptotrader.service.quoinex; import com.after_sunrise.cryptocurrency.cryptotrader.framework.Instruction.CancelInstruction; import com.after_sunrise.cryptocurrency.cryptotrader.framework.Instruction.CreateInstruction; import com.after_sunrise.cryptocurrency.cryptotrader.framework.Order; import com.after_sunrise.cryptocurrency.cryptotrader.framework.Trade; import com.after_sunrise.cryptocurrency.cryptotrader.service.template.TemplateContext; import com.google.common.annotations.VisibleForTesting; import com.google.common.reflect.TypeToken; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonDeserializer; import org.apache.commons.lang3.StringUtils; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.lang.reflect.Type; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Instant; import java.util.AbstractMap.SimpleEntry; import java.util.*; import java.util.Map.Entry; import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; import static com.after_sunrise.cryptocurrency.cryptotrader.service.template.TemplateContext.RequestType.*; import static java.lang.Boolean.FALSE; import static java.math.BigDecimal.ONE; import static java.math.BigDecimal.ZERO; import static java.util.stream.Collectors.toList; /** * @author takanori.takase * @version 0.0.1 */ public class QuoinexContext extends TemplateContext implements QuoinexService { private static final String ENDPOINT = "https://api.liquid.com"; private static final String PAGE_LIMIT = "1000"; private static final Type TYPE_PRODUCT = new TypeToken<List<QuoinexProduct>>() { }.getType(); private static final Type TYPE_ACCOUNT = new TypeToken<List<QuoinexAccount>>() { }.getType(); private final Gson gson; private final String jwtHead; public QuoinexContext() { super(ID); gson = new GsonBuilder() .registerTypeAdapter(Instant.class, (JsonDeserializer<Instant>) (j, t, c) -> Instant.ofEpochSecond(j.getAsLong()) ) .registerTypeAdapter(BigDecimal.class, (JsonDeserializer<BigDecimal>) (j, t, c) -> StringUtils.isEmpty(j.getAsString()) ? null : j.getAsBigDecimal() ) .create(); jwtHead = Base64.getUrlEncoder().encodeToString(gson.toJson(Stream.of( new SimpleEntry<>("typ", "JWT"), new SimpleEntry<>("alg", "HS256") ).collect(Collectors.toMap(Entry::getKey, Entry::getValue))).getBytes()); } @VisibleForTesting protected Optional<QuoinexProduct> fetchProduct(Key key) { ProductType product = ProductType.find(key.getInstrument()); if (product == null) { return Optional.empty(); } Key newKey = Key.build(key).instrument(WILDCARD).build(); List<QuoinexProduct> value = listCached(QuoinexProduct.class, newKey, () -> { String data = request(ENDPOINT + "/products"); if (StringUtils.isEmpty(data)) { return null; } return gson.fromJson(data, TYPE_PRODUCT); }); return trimToEmpty(value).stream() .filter(Objects::nonNull) .filter(p -> StringUtils.isNotEmpty(p.getId())) .filter(p -> StringUtils.isNotEmpty(p.getCode())) .filter(p -> StringUtils.equals(p.getCode(), product.getCode())) .findFirst(); } protected Optional<QuoinexBook> fetchBook(Key key) { return fetchProduct(key).map(product -> findCached(QuoinexBook.class, key, () -> { String data = request(ENDPOINT + "/products/" + product.getId() + "/price_levels"); if (StringUtils.isEmpty(data)) { return null; } return gson.fromJson(data, QuoinexBook.class); })); } @Override public BigDecimal getBestAskPrice(Key key) { return fetchBook(key).map(QuoinexBook::getAskPrices) .map(NavigableMap::firstEntry).map(Entry::getKey).orElse(null); } @Override public BigDecimal getBestBidPrice(Key key) { return fetchBook(key).map(QuoinexBook::getBidPrices) .map(NavigableMap::firstEntry).map(Entry::getKey).orElse(null); } @Override public BigDecimal getBestAskSize(Key key) { return fetchBook(key).map(QuoinexBook::getAskPrices) .map(NavigableMap::firstEntry).map(Entry::getValue).orElse(null); } @Override public BigDecimal getBestBidSize(Key key) { return fetchBook(key).map(QuoinexBook::getBidPrices) .map(NavigableMap::firstEntry).map(Entry::getValue).orElse(null); } @Override public Map<BigDecimal, BigDecimal> getAskPrices(Key key) { return fetchBook(key).map(QuoinexBook::getAskPrices).orElse(null); } @Override public Map<BigDecimal, BigDecimal> getBidPrices(Key key) { return fetchBook(key).map(QuoinexBook::getBidPrices).orElse(null); } @Override public BigDecimal getLastPrice(Key key) { return fetchProduct(key).map(QuoinexProduct::getLastPrice).orElse(null); } @Override public List<Trade> listTrades(Key key, Instant fromTime) { List<QuoinexTrade> trades = fetchProduct(key).map(product -> { QuoinexTrade.Container container = findCached(QuoinexTrade.Container.class, key, () -> { Map<String, String> parameters = new LinkedHashMap<>(); parameters.put("product_id", product.getId()); parameters.put("limit", PAGE_LIMIT); String queryParameter = buildQueryParameter(parameters); String data = request(ENDPOINT + "/executions" + queryParameter); if (StringUtils.isEmpty(data)) { return null; } return gson.fromJson(data, QuoinexTrade.Container.class); }); return container.getTrades(); }).orElseGet(Collections::emptyList); return trimToEmpty(trades).stream() .filter(Objects::nonNull) .filter(t -> t.getTimestamp() != null) .filter(t -> fromTime == null || !t.getTimestamp().isBefore(fromTime)) .collect(toList()); } @Override public CurrencyType getInstrumentCurrency(Key key) { ProductType product = ProductType.find(key.getInstrument()); return product == null ? null : product.getInstrumentCurrency(); } @Override public CurrencyType getFundingCurrency(Key key) { ProductType product = ProductType.find(key.getInstrument()); return product == null ? null : product.getFundingCurrency(); } @Override public String findProduct(Key key, CurrencyType instrument, CurrencyType funding) { for (ProductType product : ProductType.values()) { if (product.getInstrumentCurrency() != instrument) { continue; } if (product.getFundingCurrency() != funding) { continue; } return product.name(); } return null; } @Override public BigDecimal getConversionPrice(Key key, CurrencyType currency) { if (currency == null) { return null; } if (currency == getInstrumentCurrency(key)) { return ONE; } if (currency == getFundingCurrency(key)) { BigDecimal p = getMidPrice(key); return p == null ? null : p.negate(); } return null; } @VisibleForTesting String fetchPrivate(RequestType type, String path, Map<String, String> parameters, Object data) throws Exception { String key = getStringProperty("api.id", null); if (StringUtils.isEmpty(key)) { return null; } String secret = getStringProperty("api.secret", null); if (StringUtils.isEmpty(secret)) { return null; } String result; synchronized (ENDPOINT) { String parameter = buildQueryParameter(parameters); Map<String, String> jwt = new TreeMap<>(); jwt.put("nonce", String.valueOf(getNow().toEpochMilli())); jwt.put("path", path + parameter); jwt.put("token_id", key); String jwtLoad = Base64.getUrlEncoder().encodeToString(gson.toJson(jwt).getBytes()); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256")); byte[] hash = mac.doFinal((jwtHead + "." + jwtLoad).getBytes()); String jwtSign = Base64.getUrlEncoder().encodeToString(hash); Map<String, String> headers = new TreeMap<>(); headers.put("Content-Type", "application/json"); headers.put("X-Quoine-API-Version", "2"); headers.put("X-Quoine-Auth", jwtHead + "." + jwtLoad + "." + jwtSign); String json = data != null ? gson.toJson(data) : null; result = request(type, ENDPOINT + path + parameter, headers, json); TimeUnit.MILLISECONDS.sleep(1L); // Avoid duplicate nonce } return result; } @VisibleForTesting BigDecimal fetchBalance(Key key, Function<ProductType, CurrencyType> f) { ProductType product = ProductType.find(key.getInstrument()); if (product == null) { return null; } CurrencyType currency = f.apply(product); if (currency == null) { return null; } Key newKey = Key.build(key).instrument(WILDCARD).build(); List<QuoinexAccount> values = listCached(QuoinexAccount.class, newKey, () -> { String data = fetchPrivate(GET, "/accounts/balance", null, null); if (StringUtils.isEmpty(data)) { return null; } return gson.fromJson(data, TYPE_ACCOUNT); }); return trimToEmpty(values).stream() .filter(Objects::nonNull) .filter(v -> StringUtils.isNotEmpty(v.getCurrency())) .filter(v -> StringUtils.equals(v.getCurrency(), currency.name())) .map(QuoinexAccount::getBalance) .findFirst().orElse(null); } @Override public BigDecimal getInstrumentPosition(Key key) { return fetchBalance(key, ProductType::getInstrumentCurrency); } @Override public BigDecimal getFundingPosition(Key key) { return fetchBalance(key, ProductType::getFundingCurrency); } @Override public BigDecimal roundLotSize(Key key, BigDecimal value, RoundingMode mode) { ProductType product = ProductType.find(key.getInstrument()); return product == null ? null : super.round(value, mode, product.getLotSize()); } @Override public BigDecimal roundTickSize(Key key, BigDecimal value, RoundingMode mode) { ProductType product = ProductType.find(key.getInstrument()); return product == null ? null : super.round(value, mode, product.getTickSize()); } @Override public BigDecimal getCommissionRate(Key key) { return fetchProduct(key).map(product -> { BigDecimal taker = trimToZero(product.getTakerFee()); BigDecimal maker = trimToZero(product.getMakerFee()); return taker.max(maker).max(ZERO); }).orElse(null); } @Override public Boolean isMarginable(Key key) { return FALSE; } @Override public QuoinexOrder findOrder(Key key, String id) { if (StringUtils.isEmpty(id)) { return null; } Key newKey = Key.build(key).instrument(key.getInstrument() + "@" + id).build(); return findCached(QuoinexOrder.class, newKey, () -> { String data = fetchPrivate(GET, "/orders/" + id, null, null); if (StringUtils.isEmpty(data)) { return null; } return gson.fromJson(data, QuoinexOrder.class); }); } @Override public List<Order> listActiveOrders(Key key) { List<QuoinexOrder> orders = fetchProduct(key).map(product -> listCached(QuoinexOrder.class, key, () -> { Map<String, String> parameters = new TreeMap<>(); parameters.put("product_id", product.getId()); parameters.put("status", "live"); String data = fetchPrivate(GET, "/orders", parameters, null); if (StringUtils.isEmpty(data)) { return null; } return gson.fromJson(data, QuoinexOrder.Container.class).getValues(); })).orElseGet(Collections::emptyList); return trimToEmpty(orders).stream().filter(Objects::nonNull).collect(toList()); } @Override public List<Order.Execution> listExecutions(Key key) { List<QuoinexExecution> values = fetchProduct(key).map(product -> listCached(QuoinexExecution.class, key, () -> { Map<String, String> parameters = Collections.singletonMap("product_id", product.getId()); String data = fetchPrivate(GET, "/executions/me", parameters, null); if (StringUtils.isEmpty(data)) { return null; } return gson.fromJson(data, QuoinexExecution.Container.class).getValues(); })).orElseGet(Collections::emptyList); return trimToEmpty(values).stream().filter(Objects::nonNull).collect(toList()); } @Override public Map<CreateInstruction, String> createOrders(Key key, Set<CreateInstruction> instructions) { String id = fetchProduct(key).map(QuoinexProduct::getId).orElse(null); if (StringUtils.isEmpty(id)) { return null; } Map<CreateInstruction, String> ids = new IdentityHashMap<>(); for (CreateInstruction i : trimToEmpty(instructions)) { if (i == null || i.getPrice() == null || i.getSize() == null || i.getSize().signum() == 0) { ids.put(i, null); continue; } try { Map<String, String> parameters = new TreeMap<>(); parameters.put("product_id", id); parameters.put("order_type", i.getPrice().signum() == 0 ? "market" : "limit"); parameters.put("price", i.getPrice().signum() == 0 ? null : i.getPrice().toPlainString()); parameters.put("side", i.getSize().signum() > 0 ? "buy" : "sell"); parameters.put("quantity", i.getSize().abs().toPlainString()); String data = fetchPrivate(POST, "/orders", null, parameters); QuoinexOrder order = gson.fromJson(data, QuoinexOrder.class); ids.put(i, order.getId()); } catch (Exception e) { log.warn("Order create failure : " + i, e); ids.put(i, null); } } return ids; } @Override public Map<CancelInstruction, String> cancelOrders(Key key, Set<CancelInstruction> instructions) { Map<CancelInstruction, String> ids = new IdentityHashMap<>(); for (CancelInstruction i : trimToEmpty(instructions)) { if (i == null || StringUtils.isEmpty(i.getId())) { ids.put(i, null); continue; } try { String data = fetchPrivate(PUT, "/orders/" + i.getId() + "/cancel", null, null); QuoinexOrder order = gson.fromJson(data, QuoinexOrder.class); ids.put(i, order.getId()); } catch (Exception e) { log.warn("Order cancel failure : " + i, e); ids.put(i, null); } } return ids; } }