diff --git a/bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/KrakenExchangeAdapter.java b/bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/KrakenExchangeAdapter.java index f8b47b349..3f0a443b6 100644 --- a/bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/KrakenExchangeAdapter.java +++ b/bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/KrakenExchangeAdapter.java @@ -35,12 +35,17 @@ import com.gazbert.bxbot.exchanges.trading.api.impl.BalanceInfoImpl; import com.gazbert.bxbot.exchanges.trading.api.impl.MarketOrderBookImpl; import com.gazbert.bxbot.exchanges.trading.api.impl.MarketOrderImpl; +import com.gazbert.bxbot.exchanges.trading.api.impl.OhlcFrameImpl; +import com.gazbert.bxbot.exchanges.trading.api.impl.OhlcImpl; import com.gazbert.bxbot.exchanges.trading.api.impl.OpenOrderImpl; import com.gazbert.bxbot.exchanges.trading.api.impl.TickerImpl; import com.gazbert.bxbot.trading.api.BalanceInfo; import com.gazbert.bxbot.trading.api.ExchangeNetworkException; import com.gazbert.bxbot.trading.api.MarketOrder; import com.gazbert.bxbot.trading.api.MarketOrderBook; +import com.gazbert.bxbot.trading.api.Ohlc; +import com.gazbert.bxbot.trading.api.OhlcFrame; +import com.gazbert.bxbot.trading.api.OhlcInterval; import com.gazbert.bxbot.trading.api.OpenOrder; import com.gazbert.bxbot.trading.api.OrderType; import com.gazbert.bxbot.trading.api.Ticker; @@ -49,12 +54,14 @@ import com.google.common.base.MoreObjects; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.annotations.SerializedName; import com.google.gson.reflect.TypeToken; +import java.io.Serializable; import java.lang.reflect.Type; import java.math.BigDecimal; import java.math.RoundingMode; @@ -68,6 +75,9 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.text.DecimalFormat; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Base64; import java.util.Date; @@ -157,6 +167,8 @@ public final class KrakenExchangeAdapter extends AbstractExchangeAdapter "Failed to get Balance from exchange. Details: "; private static final String FAILED_TO_GET_TICKER = "Failed to get Ticker from exchange. Details: "; + private static final String FAILED_TO_GET_OHLC = + "Failed to get OHLC Data from exchange. Details: "; private static final String FAILED_TO_GET_OPEN_ORDERS = "Failed to get Open Orders from exchange. Details: "; @@ -599,6 +611,92 @@ public Ticker getTicker(String marketId) throws TradingApiException, ExchangeNet } } + @Override + public Ohlc getOhlc(String marketId, OhlcInterval interval) + throws TradingApiException, ExchangeNetworkException { + return getOhlc(marketId, interval, null); + } + + @Override + public Ohlc getOhlc(String marketId, OhlcInterval interval, Integer resumeID) + throws TradingApiException, ExchangeNetworkException { + ExchangeHttpResponse response; + String intervalInMinutes = calculateMinuteParamFrom(interval); + + try { + final Map params = createRequestParamMap(); + params.put("pair", marketId); + params.put("interval", intervalInMinutes); + if (resumeID != null) { + params.put("since", String.valueOf(resumeID)); + } + + response = sendPublicRequestToExchange("OHLC", params); + LOG.debug(() -> "OHLC response: " + response); + + if (response.getStatusCode() == HttpURLConnection.HTTP_OK) { + + final Type resultType = new TypeToken>() {}.getType(); + final KrakenResponse krakenResponse = + gson.fromJson(response.getPayload(), resultType); + + final List errors = krakenResponse.error; + if (errors == null || errors.isEmpty()) { + + // Assume we'll always get something here if errors array is empty; else blow fast wih NPE + return krakenResponse.result.parseOhlcResult(); + + } else { + if (isExchangeUndergoingMaintenance(response) && keepAliveDuringMaintenance) { + LOG.warn(() -> UNDER_MAINTENANCE_WARNING_MESSAGE); + throw new ExchangeNetworkException(UNDER_MAINTENANCE_WARNING_MESSAGE); + } + + final String errorMsg = FAILED_TO_GET_OHLC + response; + LOG.error(errorMsg); + throw new TradingApiException(errorMsg); + } + + } else { + final String errorMsg = FAILED_TO_GET_OHLC + response; + LOG.error(errorMsg); + throw new TradingApiException(errorMsg); + } + + } catch (ExchangeNetworkException | TradingApiException e) { + throw e; + + } catch (Exception e) { + LOG.error(UNEXPECTED_ERROR_MSG, e); + throw new TradingApiException(UNEXPECTED_ERROR_MSG, e); + } + } + + private String calculateMinuteParamFrom(OhlcInterval interval) { + switch (interval) { + case OneMinute: + return "1"; + case FiveMinutes: + return "5"; + case FifteenMinutes: + return "15"; + case HalfHour: + return "30"; + case OneHour: + return "60"; + case FourHours: + return "240"; + case OneDay: + return "1440"; + case OneWeek: + return "10080"; + case FifteenDays: + return "21600"; + default: + return null; + } + } + // -------------------------------------------------------------------------- // GSON classes for JSON responses. // See https://www.kraken.com/en-gb/help/api @@ -812,6 +910,46 @@ private static class KrakenMarketOrder extends ArrayList { private static final long serialVersionUID = -4959711260742077759L; } + private static class KrakenOhlcResult extends HashMap implements Serializable { + private static final long serialVersionUID = 5632663477504483978L; + + Ohlc parseOhlcResult() { + Gson gson = new Gson(); + List frames = new ArrayList<>(); + Integer last = null; + if (this.entrySet().size() != 2) { + throw new IllegalStateException( + "Unknown OHLC result from the API. Maybe the api changed?" + + "Known are currently only 'last' and the required frames in ''"); + } + for (Entry entry : this.entrySet()) { + if (entry.getKey().equalsIgnoreCase("last")) { + JsonElement resumeId = gson.toJsonTree(entry).getAsJsonObject().get("value"); + last = resumeId.getAsInt(); + } else { + JsonElement jsonElement = gson.toJsonTree(entry); + for (JsonElement frame : jsonElement.getAsJsonObject().get("value").getAsJsonArray()) { + if (frame.isJsonArray()) { + JsonArray frameAsArray = frame.getAsJsonArray(); + Instant timeInstant = Instant.ofEpochSecond(frameAsArray.get(0).getAsInt()); + ZonedDateTime time = ZonedDateTime.ofInstant(timeInstant, ZoneId.systemDefault()); + BigDecimal open = frameAsArray.get(1).getAsBigDecimal(); + BigDecimal high = frameAsArray.get(2).getAsBigDecimal(); + BigDecimal low = frameAsArray.get(3).getAsBigDecimal(); + BigDecimal close = frameAsArray.get(4).getAsBigDecimal(); + BigDecimal vwap = frameAsArray.get(5).getAsBigDecimal(); + BigDecimal volume = frameAsArray.get(6).getAsBigDecimal(); + Integer count = frameAsArray.get(7).getAsInt(); + + frames.add(new OhlcFrameImpl(time, open, high, low, close, vwap, volume, count)); + } + } + } + } + return new OhlcImpl(last, frames); + } + } + /** * Custom GSON Deserializer for Ticker API call result. * @@ -1091,8 +1229,8 @@ private void loadPairPrecisionConfig() { if (response.getStatusCode() == HttpURLConnection.HTTP_OK) { Type type = new TypeToken>() {}.getType(); - KrakenResponse krakenResponse = gson.fromJson( - response.getPayload(), type); + KrakenResponse krakenResponse = + gson.fromJson(response.getPayload(), type); if (krakenResponse.error != null && !krakenResponse.error.isEmpty()) { LOG.error( diff --git a/bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/trading/api/impl/OhlcFrameImpl.java b/bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/trading/api/impl/OhlcFrameImpl.java new file mode 100644 index 000000000..abf9a5ae2 --- /dev/null +++ b/bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/trading/api/impl/OhlcFrameImpl.java @@ -0,0 +1,102 @@ +package com.gazbert.bxbot.exchanges.trading.api.impl; + +import com.gazbert.bxbot.trading.api.OhlcFrame; +import com.google.common.base.MoreObjects; +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +public class OhlcFrameImpl implements OhlcFrame { + private final ZonedDateTime time; + private final BigDecimal open; + private final BigDecimal high; + private final BigDecimal low; + private final BigDecimal close; + private final BigDecimal vwap; + private final BigDecimal volume; + private final Integer count; + + /** + * Class representing a frame of the OHLC download. + * + * @param time the starttime of the corresponding ohlc frame + * @param open the open price of the corresponding ohl frame + * @param high the high price of the corresponding ohl frame + * @param low the low price of the corresponding ohl frame + * @param close the close price of the corresponding ohl frame + * @param vwap the volume-weighted avaerage price of the corresponding ohl frame + * @param volume the volume traded in the corresponding ohl frame + * @param count the trades cound of the corresponding ohl frame + */ + public OhlcFrameImpl( + ZonedDateTime time, + BigDecimal open, + BigDecimal high, + BigDecimal low, + BigDecimal close, + BigDecimal vwap, + BigDecimal volume, + Integer count) { + this.time = time; + this.open = open; + this.high = high; + this.low = low; + this.close = close; + this.vwap = vwap; + this.volume = volume; + this.count = count; + } + + @Override + public ZonedDateTime getTime() { + return time; + } + + @Override + public BigDecimal getOpen() { + return open; + } + + @Override + public BigDecimal getHigh() { + return high; + } + + @Override + public BigDecimal getLow() { + return low; + } + + @Override + public BigDecimal getClose() { + return close; + } + + @Override + public BigDecimal getVwap() { + return vwap; + } + + @Override + public BigDecimal getVolume() { + return volume; + } + + @Override + public Integer getCount() { + return count; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("time", time) + .add("open", open) + .add("high", high) + .add("low", low) + .add("close", close) + .add("vwap", vwap) + .add("volume", volume) + .add("count", count) + .toString(); + } +} diff --git a/bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/trading/api/impl/OhlcImpl.java b/bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/trading/api/impl/OhlcImpl.java new file mode 100644 index 000000000..e7861bc9d --- /dev/null +++ b/bxbot-exchanges/src/main/java/com/gazbert/bxbot/exchanges/trading/api/impl/OhlcImpl.java @@ -0,0 +1,32 @@ +package com.gazbert.bxbot.exchanges.trading.api.impl; + +import com.gazbert.bxbot.trading.api.Ohlc; +import com.gazbert.bxbot.trading.api.OhlcFrame; +import java.util.List; + +public class OhlcImpl implements Ohlc { + private final Integer resumeID; + private final List frames; + + /** + * This class represents the OHLC result. It contains all frames and an ID from which subsequent + * calls can resume fetching OHLC data + * + * @param resumeID the ID which can be used to resume OHLC download by ignoring all order results + * @param frames the OHLC frames in the requested packaging interval + */ + public OhlcImpl(Integer resumeID, List frames) { + this.resumeID = resumeID; + this.frames = frames; + } + + @Override + public Integer getResumeID() { + return resumeID; + } + + @Override + public List getFrames() { + return frames; + } +} diff --git a/bxbot-exchanges/src/test/java/com/gazbert/bxbot/exchanges/TestKrakenExchangeAdapter.java b/bxbot-exchanges/src/test/java/com/gazbert/bxbot/exchanges/TestKrakenExchangeAdapter.java index 0f13141ca..5f1737378 100644 --- a/bxbot-exchanges/src/test/java/com/gazbert/bxbot/exchanges/TestKrakenExchangeAdapter.java +++ b/bxbot-exchanges/src/test/java/com/gazbert/bxbot/exchanges/TestKrakenExchangeAdapter.java @@ -30,6 +30,7 @@ import static org.easymock.EasyMock.eq; import static org.easymock.EasyMock.expect; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; @@ -44,6 +45,9 @@ import com.gazbert.bxbot.trading.api.BalanceInfo; import com.gazbert.bxbot.trading.api.ExchangeNetworkException; import com.gazbert.bxbot.trading.api.MarketOrderBook; +import com.gazbert.bxbot.trading.api.Ohlc; +import com.gazbert.bxbot.trading.api.OhlcFrame; +import com.gazbert.bxbot.trading.api.OhlcInterval; import com.gazbert.bxbot.trading.api.OpenOrder; import com.gazbert.bxbot.trading.api.OrderType; import com.gazbert.bxbot.trading.api.Ticker; @@ -1091,6 +1095,35 @@ public void testGettingExchangeSellingFeeIsAsExpected() throws Exception { PowerMock.verifyAll(); } + @Test + public void testGetOhlcWorksAsExpected() throws Exception { + PowerMock.replayAll(); + + final ExchangeAdapter exchangeAdapter = new KrakenExchangeAdapter(); + exchangeAdapter.init(exchangeConfig); + + final Ohlc firstOhlc = + exchangeAdapter.getOhlc(MARKET_ID, OhlcInterval.FiveMinutes); + assertFalse(firstOhlc.getFrames().isEmpty()); + + for (OhlcFrame frame : firstOhlc.getFrames()) { + assertTrue(frame.getHigh().compareTo(frame.getClose()) >= 0); + assertTrue(frame.getLow().compareTo(frame.getClose()) <= 0); + assertTrue(frame.getOpen().compareTo(frame.getHigh()) <= 0); + assertTrue(frame.getOpen().compareTo(frame.getLow()) >= 0); + assertTrue(frame.toString().contains("open")); + assertTrue(frame.toString().contains("high")); + assertTrue(frame.toString().contains("low")); + assertTrue(frame.toString().contains("close")); + } + + final Ohlc onlyNewestUpdates = + exchangeAdapter.getOhlc(MARKET_ID, OhlcInterval.FiveMinutes, firstOhlc.getResumeID()); + assertEquals(1, onlyNewestUpdates.getFrames().size()); + + PowerMock.verifyAll(); + } + @Test public void testGettingExchangeBuyingFeeIsAsExpected() throws Exception { PowerMock.replayAll(); diff --git a/bxbot-trading-api/src/main/java/com/gazbert/bxbot/trading/api/Ohlc.java b/bxbot-trading-api/src/main/java/com/gazbert/bxbot/trading/api/Ohlc.java new file mode 100644 index 000000000..1299cb750 --- /dev/null +++ b/bxbot-trading-api/src/main/java/com/gazbert/bxbot/trading/api/Ohlc.java @@ -0,0 +1,9 @@ +package com.gazbert.bxbot.trading.api; + +import java.util.List; + +public interface Ohlc { + Integer getResumeID(); + + List getFrames(); +} diff --git a/bxbot-trading-api/src/main/java/com/gazbert/bxbot/trading/api/OhlcFrame.java b/bxbot-trading-api/src/main/java/com/gazbert/bxbot/trading/api/OhlcFrame.java new file mode 100644 index 000000000..cfbacfcc5 --- /dev/null +++ b/bxbot-trading-api/src/main/java/com/gazbert/bxbot/trading/api/OhlcFrame.java @@ -0,0 +1,23 @@ +package com.gazbert.bxbot.trading.api; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +public interface OhlcFrame { + + ZonedDateTime getTime(); + + BigDecimal getOpen(); + + BigDecimal getHigh(); + + BigDecimal getLow(); + + BigDecimal getClose(); + + BigDecimal getVwap(); + + BigDecimal getVolume(); + + Integer getCount(); +} diff --git a/bxbot-trading-api/src/main/java/com/gazbert/bxbot/trading/api/OhlcInterval.java b/bxbot-trading-api/src/main/java/com/gazbert/bxbot/trading/api/OhlcInterval.java new file mode 100644 index 000000000..952334d34 --- /dev/null +++ b/bxbot-trading-api/src/main/java/com/gazbert/bxbot/trading/api/OhlcInterval.java @@ -0,0 +1,13 @@ +package com.gazbert.bxbot.trading.api; + +public enum OhlcInterval { + OneMinute, + FiveMinutes, + FifteenMinutes, + HalfHour, + OneHour, + FourHours, + OneDay, + OneWeek, + FifteenDays +} diff --git a/bxbot-trading-api/src/main/java/com/gazbert/bxbot/trading/api/TradingApi.java b/bxbot-trading-api/src/main/java/com/gazbert/bxbot/trading/api/TradingApi.java index f08791b1b..38cff5546 100644 --- a/bxbot-trading-api/src/main/java/com/gazbert/bxbot/trading/api/TradingApi.java +++ b/bxbot-trading-api/src/main/java/com/gazbert/bxbot/trading/api/TradingApi.java @@ -24,6 +24,7 @@ package com.gazbert.bxbot.trading.api; import java.math.BigDecimal; +import java.util.Collections; import java.util.List; /** @@ -51,7 +52,7 @@ public interface TradingApi { * @since 1.0 */ default String getVersion() { - return "1.1"; + return "1.2"; } /** @@ -284,4 +285,87 @@ public Long getTimestamp() { } }; } + + /** + * Returns the exchange OHLC data for a given market id and the prefredred interval. + * + *

Not all exchanges provide the information returned in the OHLC method or requested interval + * - you'll need to check the relevant Exchange Adapter code/Javadoc and online Exchange API + * documentation. + * + *

If the exchange does not provide the information, a enmoty result value is returned. + * + * @param marketId the id of the market. + * @param interval the interval in which the OHLC data should be requested + * @return the OHLC answer as a sorted list of the exchange OHLC data for a given market, + * seperated into the requested interval. And an resume id, if subseqeutn data is to be + * requested. Not all exchanges support this + * @throws ExchangeNetworkException if a network error occurred trying to connect to the exchange. + * This is implementation specific for each Exchange Adapter - see the documentation for the + * adapter you are using. You could retry the API call, or exit from your Trading Strategy and + * let the Trading Engine execute your Trading Strategy at the next trade cycle. + * @throws TradingApiException if the API call failed for any reason other than a network error. + * This means something bad as happened; you would probably want to wrap this exception in a + * StrategyException and let the Trading Engine shutdown the bot immediately to prevent + * unexpected losses. + * @since 1.2 + */ + default Ohlc getOhlc(String marketId, OhlcInterval interval) + throws TradingApiException, ExchangeNetworkException { + + return new Ohlc() { + @Override + public Integer getResumeID() { + return null; + } + + @Override + public List getFrames() { + return Collections.emptyList(); + } + }; + } + + /** + * Returns the exchange OHLC data for a given market id in the prefredred interval. The market + * data queried is limitited by the resumeID, which must be catched first with an initial OHLC + * call. + * + *

Not all exchanges provide the information returned in the OHLC method or requested interval + * - you'll need to check the relevant Exchange Adapter code/Javadoc and online Exchange API + * documentation. + * + *

If the exchange does not provide the information, a enmoty result value is returned. + * + * @param marketId the id of the market. + * @param interval the interval in which the OHLC data should be requested + * @param resumeID the ID from which the OHLC data retrieval should be resumed + * @return the OHLC answer as a sorted list of the exchange OHLC data for a given market, + * seperated into the requested interval. And an resume id, if subseqeutn data is to be + * requested. Not all exchanges support this + * @throws ExchangeNetworkException if a network error occurred trying to connect to the exchange. + * This is implementation specific for each Exchange Adapter - see the documentation for the + * adapter you are using. You could retry the API call, or exit from your Trading Strategy and + * let the Trading Engine execute your Trading Strategy at the next trade cycle. + * @throws TradingApiException if the API call failed for any reason other than a network error. + * This means something bad as happened; you would probably want to wrap this exception in a + * StrategyException and let the Trading Engine shutdown the bot immediately to prevent + * unexpected losses. + * @since 1.2 + */ + default Ohlc getOhlc(String marketId, OhlcInterval interval, Integer resumeID) + throws TradingApiException, ExchangeNetworkException { + + return new Ohlc() { + @Override + public Integer getResumeID() { + return null; + } + + @Override + public List getFrames() { + return Collections.emptyList(); + } + }; + } } diff --git a/bxbot-trading-api/src/test/java/com/gazbert/bxbot/trading/api/TestTradingApi.java b/bxbot-trading-api/src/test/java/com/gazbert/bxbot/trading/api/TestTradingApi.java index 42123c681..94d899f46 100644 --- a/bxbot-trading-api/src/test/java/com/gazbert/bxbot/trading/api/TestTradingApi.java +++ b/bxbot-trading-api/src/test/java/com/gazbert/bxbot/trading/api/TestTradingApi.java @@ -26,11 +26,13 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import java.math.BigDecimal; import java.util.List; import org.junit.Test; + /** * Tests default impl methods of TradingApi interface. * @@ -41,7 +43,7 @@ public class TestTradingApi { @Test public void testGetVersion() { final MyApiImpl myApi = new MyApiImpl(); - assertEquals("1.1", myApi.getVersion()); + assertEquals("1.2", myApi.getVersion()); } @Test @@ -61,6 +63,26 @@ public void testGetTicker() throws Exception { assertNull(ticker.getTimestamp()); } + @Test + public void testGetOhlcHasDefaultImpl() throws Exception { + final MyApiImpl myApi = new MyApiImpl(); + final Ohlc ohlc = myApi.getOhlc("market-123", OhlcInterval.OneWeek); + + assertNotNull(ohlc); + assertNull(ohlc.getResumeID()); + assertTrue(ohlc.getFrames().isEmpty()); + } + + @Test + public void testGetOhlcResumeHasDefaultImpl() throws Exception { + final MyApiImpl myApi = new MyApiImpl(); + final Ohlc ohlc = myApi.getOhlc("market-123", OhlcInterval.OneWeek, 5); + + assertNotNull(ohlc); + assertNull(ohlc.getResumeID()); + assertTrue(ohlc.getFrames().isEmpty()); + } + /** Test class. */ class MyApiImpl implements TradingApi {