package com.after_sunrise.cryptocurrency.cryptotrader.service.bitbank; import cc.bitbank.Bitbankcc; import cc.bitbank.entity.Assets; import cc.bitbank.entity.Depth; import cc.bitbank.entity.Orders; import cc.bitbank.entity.Transactions; import cc.bitbank.entity.enums.CurrencyPair; import cc.bitbank.entity.enums.OrderSide; import cc.bitbank.entity.enums.OrderType; 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.Order.Execution; import com.after_sunrise.cryptocurrency.cryptotrader.framework.Trade; import com.after_sunrise.cryptocurrency.cryptotrader.service.bitbank.BitbankOrder.BitbankExecution; import com.after_sunrise.cryptocurrency.cryptotrader.service.template.TemplateContext; import com.google.common.annotations.VisibleForTesting; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Instant; import java.time.ZonedDateTime; import java.util.*; import java.util.concurrent.ConcurrentSkipListMap; import java.util.regex.Pattern; import java.util.stream.Stream; import static java.lang.Boolean.FALSE; import static java.math.BigDecimal.ONE; import static java.math.BigDecimal.ZERO; import static java.util.Collections.*; import static java.util.stream.Collectors.toList; /** * @author takanori.takase * @version 0.0.1 */ public class BitbankContext extends TemplateContext implements BitbankService { private static final Pattern NUMERIC = Pattern.compile("^[0-9]+$"); private final ThreadLocal<Bitbankcc> localApi; private final NavigableMap<Long, BitbankOrder> cachedOrders; public BitbankContext() { super(ID); localApi = ThreadLocal.withInitial(Bitbankcc::new); cachedOrders = new ConcurrentSkipListMap<>(); } @VisibleForTesting NavigableMap<Long, BitbankOrder> getCachedOrders() { return cachedOrders; } @VisibleForTesting Bitbankcc getLocalApi() { String apiKey = getStringProperty("api.id", null); String secret = getStringProperty("api.secret", null); return localApi.get().setKey(apiKey, secret); } @VisibleForTesting Optional<BitbankDepth> fetchDepth(Key key) { return Optional.ofNullable(findCached(BitbankDepth.class, key, () -> { ProductType product = ProductType.find(key.getInstrument()); if (product == null || product.getPair() == null) { return null; } Depth depth = getLocalApi().getDepth(product.getPair()); if (depth == null) { return null; } return new BitbankDepth(depth); })); } @Override public BigDecimal getBestAskPrice(Key key) { return fetchDepth(key).map(BitbankDepth::getAsks) .map(NavigableMap::firstEntry) .map(Map.Entry::getKey).orElse(null); } @Override public BigDecimal getBestBidPrice(Key key) { return fetchDepth(key).map(BitbankDepth::getBids) .map(NavigableMap::firstEntry) .map(Map.Entry::getKey).orElse(null); } @Override public BigDecimal getBestAskSize(Key key) { return fetchDepth(key).map(BitbankDepth::getAsks) .map(NavigableMap::firstEntry) .map(Map.Entry::getValue).orElse(null); } @Override public BigDecimal getBestBidSize(Key key) { return fetchDepth(key).map(BitbankDepth::getBids) .map(NavigableMap::firstEntry) .map(Map.Entry::getValue).orElse(null); } @Override public Map<BigDecimal, BigDecimal> getAskPrices(Key key) { return fetchDepth(key).map(BitbankDepth::getAsks).orElse(null); } @Override public Map<BigDecimal, BigDecimal> getBidPrices(Key key) { return fetchDepth(key).map(BitbankDepth::getBids).orElse(null); } @VisibleForTesting List<BitbankTransaction> fetchTransactions(Key key) { return listCached(BitbankTransaction.class, key, () -> { ProductType product = ProductType.find(key.getInstrument()); if (product == null) { return emptyList(); } Transactions t = getLocalApi().getTransaction(product.getPair()); if (t == null || ArrayUtils.isEmpty(t.transactions)) { return emptyList(); } return unmodifiableList(Stream.of(t.transactions) .filter(Objects::nonNull) .map(BitbankTransaction::new) .collect(toList()) ); }); } @Override public BigDecimal getLastPrice(Key key) { return trimToEmpty(fetchTransactions(key)).stream() .filter(Objects::nonNull) .filter(t -> t.getTimestamp() != null) .max(Comparator.comparing(BitbankTransaction::getTimestamp)) .map(BitbankTransaction::getPrice) .orElse(null); } @Override public List<Trade> listTrades(Key key, Instant fromTime) { return trimToEmpty(fetchTransactions(key)).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 (instrument != product.getInstrumentCurrency()) { continue; } if (funding != product.getFundingCurrency()) { 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 BigDecimal fetchBalance(Key key, CurrencyType currency) { if (currency == null) { return null; } Key k = Key.build(key).instrument(WILDCARD).build(); List<BitbankAsset> assets = trimToEmpty(listCached(BitbankAsset.class, k, () -> { Assets a = getLocalApi().getAsset(); if (a == null || ArrayUtils.isEmpty(a.assets)) { return emptyList(); } return Stream.of(a.assets).filter(Objects::nonNull) .map(BitbankAsset::new).collect(toList()); })); String id = StringUtils.trimToEmpty(currency.name()); return trimToEmpty(assets).stream() .filter(Objects::nonNull) .filter(a -> id.equalsIgnoreCase(a.getId())) .findFirst().map(BitbankAsset::getBalance).orElse(null); } @Override public BigDecimal getInstrumentPosition(Key key) { ProductType product = ProductType.find(key.getInstrument()); return product == null ? null : fetchBalance(key, product.getInstrumentCurrency()); } @Override public BigDecimal getFundingPosition(Key key) { ProductType product = ProductType.find(key.getInstrument()); return product == null ? null : fetchBalance(key, product.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) { ProductType product = ProductType.find(key.getInstrument()); if (product == null) { return null; } return getDecimalProperty("commission." + product.name(), ZERO); } @Override public Boolean isMarginable(Key key) { return FALSE; } @Override public ZonedDateTime getExpiry(Key key) { return null; } @Override public BitbankOrder findOrder(Key key, String id) { if (StringUtils.isEmpty(id) || !NUMERIC.matcher(id).matches()) { return null; } ProductType product = ProductType.find(key.getInstrument()); if (product == null) { return null; } Key idKey = Key.build(key).instrument(key.getInstrument() + "@" + id).build(); return findCached(BitbankOrder.class, idKey, () -> { cc.bitbank.entity.Order o = getLocalApi().getOrder(product.getPair(), Long.valueOf(id)); return o == null ? null : new BitbankOrder(o); }); } @VisibleForTesting List<BitbankOrder> fetchActiveOrders(Key key) { ProductType product = ProductType.find(key.getInstrument()); if (product == null) { return emptyList(); } List<BitbankOrder> orders = listCached(BitbankOrder.class, key, () -> { Map<String, Long> options = singletonMap("count", 100L); Orders result = getLocalApi().getActiveOrders(product.getPair(), options); if (result == null || ArrayUtils.isEmpty(result.orders)) { return emptyList(); } return Collections.unmodifiableList( Stream.of(result.orders).filter(Objects::nonNull).map(BitbankOrder::new).collect(toList()) ); }); return orders; } @Override public List<Order> listActiveOrders(Key key) { List<BitbankOrder> orders = trimToEmpty(fetchActiveOrders(key)); return orders.stream().filter(Objects::nonNull).collect(toList()); } @Override public List<Execution> listExecutions(Key key) { // API not available : https://bitbank.cc/blog/20171227trade-history/ List<Execution> executions = new ArrayList<>(); for (Map.Entry<Long, BitbankOrder> entry : cachedOrders.entrySet()) { Long id = entry.getKey(); BitbankOrder order = entry.getValue(); if (order == null || !Objects.equals(FALSE, order.getActive())) { order = findOrder(key, id.toString()); if (order != null) { cachedOrders.put(id, order); } } if (order != null) { if (order.getFilledQuantity() != null && order.getFilledQuantity().signum() != 0) { executions.add(new BitbankExecution(order.getDelegate())); continue; } if (!Objects.equals(FALSE, order.getActive())) { continue; } } cachedOrders.remove(id); } return executions; } @Override public Map<CreateInstruction, String> createOrders(Key key, Set<CreateInstruction> instructions) { Map<CreateInstruction, String> results = new IdentityHashMap<>(); for (CreateInstruction i : trimToEmpty(instructions)) { if (i == null || i.getPrice() == null || i.getSize() == null || i.getSize().signum() == 0) { results.put(i, null); continue; } ProductType product = ProductType.find(key.getInstrument()); if (product == null) { results.put(i, null); continue; } try { CurrencyPair pair = product.getPair(); BigDecimal price = i.getPrice().signum() != 0 ? i.getPrice() : null; OrderType type = i.getPrice().signum() != 0 ? OrderType.LIMIT : OrderType.MARKET; BigDecimal amount = i.getSize().abs(); OrderSide side = i.getSize().signum() >= 0 ? OrderSide.BUY : OrderSide.SELL; cc.bitbank.entity.Order order = getLocalApi().sendOrder(pair, price, amount, side, type); if (order != null && order.status != null) { results.put(i, String.valueOf(order.orderId)); int capacity = getIntProperty("cache.order", 32); while (true) { if (cachedOrders.isEmpty()) { break; } if (cachedOrders.size() < capacity) { break; } cachedOrders.pollFirstEntry(); } cachedOrders.put(order.orderId, new BitbankOrder(order)); } else { // Failed orders have empty fields results.put(i, null); } } catch (Exception e) { log.warn("Order create failure : " + i, e); results.put(i, null); } } return results; } @Override public Map<CancelInstruction, String> cancelOrders(Key key, Set<CancelInstruction> instructions) { Map<CancelInstruction, String> results = new IdentityHashMap<>(); ProductType product = ProductType.find(key.getInstrument()); if (product != null) { trimToEmpty(instructions).stream() .filter(Objects::nonNull) .filter(i -> StringUtils.isNotBlank(i.getId())) .filter(i -> NUMERIC.matcher(i.getId()).matches()) .forEach(i -> { String result; try { long id = Long.valueOf(i.getId()); cc.bitbank.entity.Order order = getLocalApi().cancelOrder(product.getPair(), id); result = order == null ? null : new BitbankOrder(order).getId(); } catch (Exception e) { log.warn("Order cancel failure : " + i.getId(), e); result = null; } results.put(i, result); }); } return results; } }