diff --git a/AGENTS.md b/AGENTS.md index 4963f39..a5d597b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ mvn -pl library package # build only the library module mvn -pl examples package # build only the examples module ``` -There are no tests in this project. CI runs `mvn clean package` on PRs targeting `master`. +The library's JUnit 5 tests use JSON fixtures and Mockito mocks of `KrakenRestRequester`, without network access or API keys. Run them with `mvn -pl library test`. CI runs `mvn clean package` on PRs targeting `master`. Java 25 with Temurin is required (configured via `maven-compiler-plugin` with `25`). @@ -19,7 +19,7 @@ Java 25 with Temurin is required (configured via `maven-compiler-plugin` with `< `KrakenAPI` is the entry point: typed methods for implemented endpoints, generic `query()` methods taking a `Public`/`Private` enum value and returning a `JsonNode`, and raw `queryPublic()`/`queryPrivate()` taking a path string. -Every endpoint extends `Endpoint`, either `PublicEndpoint` (GET on `/0/public/{path}`, parameters from `QueryParams`) or `PrivateEndpoint` (POST on `/0/private/{path}`, parameters from `PostParams`, signed with a nonce-based HMAC). Concrete endpoints live in a domain package under `endpoint/` — `market/` for public market data, `account/` for private account data, `subaccount/` for subaccount management, `transparency/` for public pre- and post-trade data, `earn/` for earn strategies and allocations — and follow `{Name}Endpoint`, `params/{Name}Params`, `response/{ResponseType}`. +Every endpoint extends `Endpoint`, either `PublicEndpoint` (GET on `/0/public/{path}`, parameters from `QueryParams`) or `PrivateEndpoint` (POST on `/0/private/{path}`, parameters from `PostParams`, signed with a nonce-based HMAC). Concrete endpoints live in a domain package under `endpoint/` — `market/` for market data, including authenticated Level3, `account/` for private account data, `subaccount/` for subaccount management, `transparency/` for public pre- and post-trade data, `earn/` for earn strategies and allocations — and follow `{Name}Endpoint`, `params/{Name}Params`, `response/{ResponseType}`. `KrakenRestRequester` performs the HTTP calls and can be swapped for another HTTP client. Responses are unwrapped from the Kraken `{error, result}` envelope by `KrakenResponse`; ZIP responses (report exports) go through `Endpoint.processZipResponse()`. diff --git a/README.md b/README.md index 1127e88..65e4dfd 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,64 @@ JsonNode trades = api.queryPublic("Trades", Map.of("pair", "XBTUSD", "count", "1 // {"XXBTZUSD":[["68515.60000","0.00029628",1.7100231295628998E9,"s","m","",68007835]], … ``` +### Market Data + +All 12 Market Data endpoints in Kraken's current Spot REST specification have typed methods. In addition to `serverTime`, `systemStatus`, `assetInfo`, `assetPairs`, and `ticker`: + +| Endpoint | Typed method | Response | +|---|---|---| +| `OHLC` | `ohlc(pair)` / `ohlc(params)` | `OhlcData`: candles by pair and a `last` cursor | +| `Depth` | `orderBook(pair)` / `orderBook(params)` | `Map` | +| `GroupedBook` | `groupedOrderBook(pair)` / `groupedOrderBook(params)` | `GroupedOrderBook` | +| `Trades` | `recentTrades(pair)` / `recentTrades(params)` | `RecentTrades`: trades by pair and a `last` cursor | +| `Spread` | `recentSpreads(pair)` / `recentSpreads(params)` | `RecentSpreads`: spreads by pair and a `last` cursor | +| `Level3` (private) | `level3OrderBook(pair)` / `level3OrderBook(params)` | `Level3OrderBook` | +| `MaintenanceSchedule` | `maintenanceSchedule()` | `MaintenanceSchedule` | + +Use parameter builders to set optional fields; omitted fields retain Kraken's defaults: + +```java +OhlcData candles = api.ohlc(OhlcParams.builder() + .pair("BTC/USD").interval(60).assetVersion(1).build()); +List hourly = candles.candles().get("BTC/USD"); + +RecentTrades trades = api.recentTrades(RecentTradesParams.builder() + .pair("BTC/USD").count(10).build()); +RecentTrades nextBatch = api.recentTrades(RecentTradesParams.builder() + .pair("BTC/USD").since(trades.last()).count(10).build()); +``` + +`OHLC`, `Depth`, `Trades`, and `Spread` accept `assetVersion(1)` for display pair keys such as `BTC/USD`; without it, Kraken returns internal keys such as `XXBTZUSD`. Their `assetClass("tokenized_asset")` option supports xStocks. Response maps preserve the keys Kraken returns. + +The required `pair` field selects one asset pair. Optional numeric fields accept the following values; omit them to use Kraken's defaults: + +| Endpoint | Option | Values | Default | +|---|---|---|---| +| `OHLC` | `interval` | 1, 5, 15, 30, 60, 240, 1440, 10080, 21600 minutes | 1 | +| `Depth` | `count` | 1–500 entries per side | 100 | +| `Trades` | `count` | 1–1000 trades | 1000 | +| `GroupedBook` | `depth` | 10, 25, 100, 250, 1000 levels per side | 10 | +| `GroupedBook` | `grouping` | 1, 5, 10, 25, 50, 100, 250, 500, 1000 ticks per level | 1 | +| `Level3` | `depth` | 0 (full book), 10, 25, 100, 250, 1000 levels per side | 100 | + +OHLC candle times, L2 level times and spread times use `Instant`; the `since` fields for OHLC and spreads remain Unix seconds. Grouped books round asks up and bids down to the nearest grouped price level. `MaintenanceSchedule` returns scheduled events for the next seven days, ordered by expected start time; its times use `Instant`, and `cancelBefore` can be absent. + +OHLC includes a final candle that is still forming and retains at most 720 entries. Reuse its `last()` cursor as `since` to poll for committed updates. Trade cursors are opaque strings: pass `last()` unchanged. Prices and quantities use `BigDecimal`; trade and Level3 times use `Instant`, retaining nanosecond precision. Level3 decodes Kraken's integer epoch nanoseconds explicitly. + +Level3 requires credentials with **Orders and trades – Query open orders & trades** permission: + +```java +KrakenAPI authenticated = new KrakenAPI("my key", "my secret"); +Level3OrderBook book = authenticated.level3OrderBook(Level3OrderBookParams.builder() + .pair("YFI/EUR").depth(10).build()); +``` + +Run the public examples without credentials (after `mvn clean install`): + +```sh +mvn -pl examples exec:java -Dexec.mainClass=dev.andstuff.kraken.example.MarketDataExample +``` + ### Private endpoints Private endpoints can be queried in the same way as the public ones, but an API key and secret must be provided to the `KrakenAPI` instance: @@ -105,22 +163,19 @@ JsonNode order = api.query(KrakenAPI.Private.ADD_ORDER, Map.of( ### Custom endpoints -An endpoint the library doesn't implement can also be given a proper type, instead of falling back to `JsonNode`. Extend `PublicEndpoint`, or `PrivateEndpoint` for a private one, and pass your endpoint to `query`: +You can also define typed endpoints outside the library. The following example demonstrates the same mechanism used by the built-in order book endpoint. Extend `PublicEndpoint`, or `PrivateEndpoint` for a private one, and pass your endpoint to `query`: ```java -public class TradesEndpoint extends PublicEndpoint>> { +public class MyOrderBookEndpoint extends PublicEndpoint> { - public TradesEndpoint(String pair) { - super("Trades", () -> Map.of("pair", pair), new TypeReference<>() {}); + public MyOrderBookEndpoint(String pair) { + super("Depth", () -> Map.of("pair", pair), new TypeReference<>() {}); } } -record Trade(BigDecimal price, BigDecimal volume) {} - KrakenAPI api = new KrakenAPI(); -Map> trades = api.query(new TradesEndpoint("XBTUSD")); -// {XXBTZUSD=[Trade[price=68515.60000, volume=0.00029628]], … +Map books = api.query(new MyOrderBookEndpoint("XBTUSD")); ``` The endpoint is run through the same `KrakenRestRequester` as the built-in ones, and a `PrivateEndpoint` is signed with the credentials and nonce generator the `KrakenAPI` instance was built with. Querying one on an instance built without credentials throws an `IllegalStateException`. diff --git a/examples/src/main/java/dev/andstuff/kraken/example/MarketDataExample.java b/examples/src/main/java/dev/andstuff/kraken/example/MarketDataExample.java new file mode 100644 index 0000000..96acfb0 --- /dev/null +++ b/examples/src/main/java/dev/andstuff/kraken/example/MarketDataExample.java @@ -0,0 +1,33 @@ +package dev.andstuff.kraken.example; + +import dev.andstuff.kraken.api.KrakenAPI; +import dev.andstuff.kraken.api.endpoint.market.params.GroupedOrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.params.OhlcParams; +import dev.andstuff.kraken.api.endpoint.market.params.OrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.params.RecentTradesParams; +import dev.andstuff.kraken.api.endpoint.market.response.OhlcData; +import dev.andstuff.kraken.api.endpoint.market.response.RecentTrades; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class MarketDataExample { + + static void main() { + KrakenAPI api = new KrakenAPI(); + String pair = "BTC/USD"; + + OhlcData candles = api.ohlc(OhlcParams.builder().pair(pair).interval(60).assetVersion(1).build()); + log.info("Hourly candles: {}", candles.candles().get(pair)); + + log.info("L2 order book: {}", api.orderBook(OrderBookParams.builder().pair(pair).count(10).build())); + log.info("Grouped book: {}", api.groupedOrderBook(GroupedOrderBookParams.builder() + .pair(pair).depth(10).grouping(1000).build())); + + RecentTrades trades = api.recentTrades(RecentTradesParams.builder().pair(pair).count(2).build()); + log.info("Recent trades: {}", trades.trades()); + log.info("Next trade cursor: {}", trades.last()); + + log.info("Recent spreads: {}", api.recentSpreads(pair)); + log.info("Upcoming maintenance: {}", api.maintenanceSchedule()); + } +} diff --git a/library/pom.xml b/library/pom.xml index f44f4cd..5700858 100644 --- a/library/pom.xml +++ b/library/pom.xml @@ -12,4 +12,22 @@ kraken-api Java Kraken API Client + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.mockito + mockito-junit-jupiter + test + + + diff --git a/library/src/main/java/dev/andstuff/kraken/api/KrakenAPI.java b/library/src/main/java/dev/andstuff/kraken/api/KrakenAPI.java index 67cf598..58395c4 100644 --- a/library/src/main/java/dev/andstuff/kraken/api/KrakenAPI.java +++ b/library/src/main/java/dev/andstuff/kraken/api/KrakenAPI.java @@ -39,12 +39,32 @@ import dev.andstuff.kraken.api.endpoint.earn.response.EarnStrategies; import dev.andstuff.kraken.api.endpoint.market.AssetInfoEndpoint; import dev.andstuff.kraken.api.endpoint.market.AssetPairEndpoint; +import dev.andstuff.kraken.api.endpoint.market.GroupedOrderBookEndpoint; +import dev.andstuff.kraken.api.endpoint.market.Level3OrderBookEndpoint; +import dev.andstuff.kraken.api.endpoint.market.MaintenanceScheduleEndpoint; +import dev.andstuff.kraken.api.endpoint.market.OhlcEndpoint; +import dev.andstuff.kraken.api.endpoint.market.OrderBookEndpoint; +import dev.andstuff.kraken.api.endpoint.market.RecentSpreadsEndpoint; +import dev.andstuff.kraken.api.endpoint.market.RecentTradesEndpoint; import dev.andstuff.kraken.api.endpoint.market.ServerTimeEndpoint; import dev.andstuff.kraken.api.endpoint.market.SystemStatusEndpoint; import dev.andstuff.kraken.api.endpoint.market.TickerEndpoint; import dev.andstuff.kraken.api.endpoint.market.params.AssetPairParams; +import dev.andstuff.kraken.api.endpoint.market.params.GroupedOrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.params.Level3OrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.params.OhlcParams; +import dev.andstuff.kraken.api.endpoint.market.params.OrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.params.RecentSpreadsParams; +import dev.andstuff.kraken.api.endpoint.market.params.RecentTradesParams; import dev.andstuff.kraken.api.endpoint.market.response.AssetInfo; import dev.andstuff.kraken.api.endpoint.market.response.AssetPairs; +import dev.andstuff.kraken.api.endpoint.market.response.GroupedOrderBook; +import dev.andstuff.kraken.api.endpoint.market.response.Level3OrderBook; +import dev.andstuff.kraken.api.endpoint.market.response.MaintenanceSchedule; +import dev.andstuff.kraken.api.endpoint.market.response.OhlcData; +import dev.andstuff.kraken.api.endpoint.market.response.OrderBook; +import dev.andstuff.kraken.api.endpoint.market.response.RecentSpreads; +import dev.andstuff.kraken.api.endpoint.market.response.RecentTrades; import dev.andstuff.kraken.api.endpoint.market.response.ServerTime; import dev.andstuff.kraken.api.endpoint.market.response.SystemStatus; import dev.andstuff.kraken.api.endpoint.market.response.Ticker; @@ -242,6 +262,126 @@ public Map ticker(List pairs) { return query(new TickerEndpoint(pairs)); } + /** + * Queries the {@code OHLC} endpoint using Kraken's default options. + * + * @param pair the asset pair to query, e.g. {@code BTC/USD} + * @return the candles by returned pair name and the cursor for committed updates + * @throws KrakenException if Kraken returns an error + */ + public OhlcData ohlc(String pair) { + return query(new OhlcEndpoint(pair)); + } + + /** + * Queries the {@code OHLC} endpoint. + * + * @param params the request parameters + * @return the candles by returned pair name and the cursor for committed updates + * @throws KrakenException if Kraken returns an error + */ + public OhlcData ohlc(OhlcParams params) { + return query(new OhlcEndpoint(params)); + } + + /** + * Queries the {@code Depth} endpoint using Kraken's default options. + * + * @param pair the asset pair to query, e.g. {@code BTC/USD} + * @return the L2 order books by returned pair name + * @throws KrakenException if Kraken returns an error + */ + public Map orderBook(String pair) { + return query(new OrderBookEndpoint(pair)); + } + + /** + * Queries the {@code Depth} endpoint. + * + * @param params the request parameters + * @return the L2 order books by returned pair name + * @throws KrakenException if Kraken returns an error + */ + public Map orderBook(OrderBookParams params) { + return query(new OrderBookEndpoint(params)); + } + + /** + * Queries the {@code Trades} endpoint using Kraken's default options. + * + * @param pair the asset pair to query, e.g. {@code BTC/USD} + * @return the trades by returned pair name and the next polling cursor + * @throws KrakenException if Kraken returns an error + */ + public RecentTrades recentTrades(String pair) { + return query(new RecentTradesEndpoint(pair)); + } + + /** + * Queries the {@code Trades} endpoint. + * + * @param params the request parameters + * @return the trades by returned pair name and the next polling cursor + * @throws KrakenException if Kraken returns an error + */ + public RecentTrades recentTrades(RecentTradesParams params) { + return query(new RecentTradesEndpoint(params)); + } + + /** + * Queries the {@code Spread} endpoint using Kraken's default options. + * + * @param pair the asset pair to query, e.g. {@code BTC/USD} + * @return the spreads by returned pair name and the next polling cursor + * @throws KrakenException if Kraken returns an error + */ + public RecentSpreads recentSpreads(String pair) { + return query(new RecentSpreadsEndpoint(pair)); + } + + /** + * Queries the {@code Spread} endpoint. + * + * @param params the request parameters + * @return the spreads by returned pair name and the next polling cursor + * @throws KrakenException if Kraken returns an error + */ + public RecentSpreads recentSpreads(RecentSpreadsParams params) { + return query(new RecentSpreadsEndpoint(params)); + } + + /** + * Queries the {@code GroupedBook} endpoint using Kraken's default options. + * + * @param pair the asset pair to query, e.g. {@code BTC/USD} + * @return the grouped bids and asks, pair and grouping value + * @throws KrakenException if Kraken returns an error + */ + public GroupedOrderBook groupedOrderBook(String pair) { + return query(new GroupedOrderBookEndpoint(pair)); + } + + /** + * Queries the {@code GroupedBook} endpoint. + * + * @param params the request parameters + * @return the grouped bids and asks, pair and grouping value + * @throws KrakenException if Kraken returns an error + */ + public GroupedOrderBook groupedOrderBook(GroupedOrderBookParams params) { + return query(new GroupedOrderBookEndpoint(params)); + } + + /** + * Queries the {@code MaintenanceSchedule} endpoint for scheduled events in the next seven days. + * + * @return the maintenance schedule + * @throws KrakenException if Kraken returns an error + */ + public MaintenanceSchedule maintenanceSchedule() { + return query(new MaintenanceScheduleEndpoint()); + } + /** * Queries the {@code PreTrade} endpoint, returning the aggregated order book of a currency pair, with at most ten price levels on each side. * @@ -277,6 +417,30 @@ public PostTrade postTrade(PostTradeParams params) { /* Implemented private endpoints */ + /** + * Queries the {@code Level3} endpoint using Kraken's default options. Requires the Orders and trades - Query open orders & trades API key permission. + * + * @param pair the asset pair to query, e.g. {@code BTC/USD} + * @return the individual bid and ask orders with IDs and nanosecond timestamps + * @throws KrakenException if Kraken returns an error + * @throws IllegalStateException if credentials are missing + */ + public Level3OrderBook level3OrderBook(String pair) { + return query(new Level3OrderBookEndpoint(pair)); + } + + /** + * Queries the {@code Level3} endpoint. Requires the Orders and trades - Query open orders & trades API key permission. + * + * @param params the request parameters + * @return the individual bid and ask orders with IDs and nanosecond timestamps + * @throws KrakenException if Kraken returns an error + * @throws IllegalStateException if credentials are missing + */ + public Level3OrderBook level3OrderBook(Level3OrderBookParams params) { + return query(new Level3OrderBookEndpoint(params)); + } + /** * Queries the private {@code Ledgers} endpoint, returning at most 50 ledger entries per call. * @@ -597,6 +761,7 @@ public enum Public { ASSET_PAIRS("AssetPairs"), DEPTH("Depth"), GROUPED_BOOK("GroupedBook"), + MAINTENANCE_SCHEDULE("MaintenanceSchedule"), OHLC("OHLC"), POST_TRADE("PostTrade"), PRE_TRADE("PreTrade"), diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/GroupedOrderBookEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/GroupedOrderBookEndpoint.java new file mode 100644 index 0000000..7b4b8ac --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/GroupedOrderBookEndpoint.java @@ -0,0 +1,31 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.market.params.GroupedOrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.response.GroupedOrderBook; +import dev.andstuff.kraken.api.endpoint.pub.PublicEndpoint; + +/** + * The public {@code GroupedBook} endpoint, returning bid and ask quantities aggregated over grouped price levels. + */ +public class GroupedOrderBookEndpoint extends PublicEndpoint { + + /** + * Creates the {@code GroupedBook} endpoint using Kraken's default options. + * + * @param pair the asset pair to query + */ + public GroupedOrderBookEndpoint(String pair) { + this(GroupedOrderBookParams.builder().pair(pair).build()); + } + + /** + * Creates the {@code GroupedBook} endpoint. + * + * @param params the request parameters + */ + public GroupedOrderBookEndpoint(GroupedOrderBookParams params) { + super("GroupedBook", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/Level3OrderBookEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/Level3OrderBookEndpoint.java new file mode 100644 index 0000000..735846b --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/Level3OrderBookEndpoint.java @@ -0,0 +1,31 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.market.params.Level3OrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.response.Level3OrderBook; +import dev.andstuff.kraken.api.endpoint.priv.PrivateEndpoint; + +/** + * The private {@code Level3} endpoint, requiring the Orders and trades - Query open orders & trades API key permission. + */ +public class Level3OrderBookEndpoint extends PrivateEndpoint { + + /** + * Creates the {@code Level3} endpoint using Kraken's default options. + * + * @param pair the asset pair to query + */ + public Level3OrderBookEndpoint(String pair) { + this(Level3OrderBookParams.builder().pair(pair).build()); + } + + /** + * Creates the {@code Level3} endpoint. + * + * @param params the request parameters + */ + public Level3OrderBookEndpoint(Level3OrderBookParams params) { + super("Level3", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/MaintenanceScheduleEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/MaintenanceScheduleEndpoint.java new file mode 100644 index 0000000..458e99e --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/MaintenanceScheduleEndpoint.java @@ -0,0 +1,19 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.market.response.MaintenanceSchedule; +import dev.andstuff.kraken.api.endpoint.pub.PublicEndpoint; + +/** + * The public {@code MaintenanceSchedule} endpoint, returning scheduled maintenance events in the next seven days. + */ +public class MaintenanceScheduleEndpoint extends PublicEndpoint { + + /** + * Creates the {@code MaintenanceSchedule} endpoint for scheduled events in the next seven days. + */ + public MaintenanceScheduleEndpoint() { + super("MaintenanceSchedule", new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/OhlcEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/OhlcEndpoint.java new file mode 100644 index 0000000..e0bac12 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/OhlcEndpoint.java @@ -0,0 +1,31 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.market.params.OhlcParams; +import dev.andstuff.kraken.api.endpoint.market.response.OhlcData; +import dev.andstuff.kraken.api.endpoint.pub.PublicEndpoint; + +/** + * The public {@code OHLC} endpoint, returning candles by asset pair and a cursor for committed updates. + */ +public class OhlcEndpoint extends PublicEndpoint { + + /** + * Creates the {@code OHLC} endpoint using Kraken's default options. + * + * @param pair the asset pair to query + */ + public OhlcEndpoint(String pair) { + this(OhlcParams.builder().pair(pair).build()); + } + + /** + * Creates the {@code OHLC} endpoint. + * + * @param params the request parameters + */ + public OhlcEndpoint(OhlcParams params) { + super("OHLC", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/OrderBookEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/OrderBookEndpoint.java new file mode 100644 index 0000000..8543828 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/OrderBookEndpoint.java @@ -0,0 +1,33 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import java.util.Map; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.market.params.OrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.response.OrderBook; +import dev.andstuff.kraken.api.endpoint.pub.PublicEndpoint; + +/** + * The public {@code Depth} endpoint, returning aggregated L2 price levels by asset pair. + */ +public class OrderBookEndpoint extends PublicEndpoint> { + + /** + * Creates the {@code Depth} endpoint using Kraken's default options. + * + * @param pair the asset pair to query + */ + public OrderBookEndpoint(String pair) { + this(OrderBookParams.builder().pair(pair).build()); + } + + /** + * Creates the {@code Depth} endpoint. + * + * @param params the request parameters + */ + public OrderBookEndpoint(OrderBookParams params) { + super("Depth", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/RecentSpreadsEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/RecentSpreadsEndpoint.java new file mode 100644 index 0000000..3128534 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/RecentSpreadsEndpoint.java @@ -0,0 +1,31 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.market.params.RecentSpreadsParams; +import dev.andstuff.kraken.api.endpoint.market.response.RecentSpreads; +import dev.andstuff.kraken.api.endpoint.pub.PublicEndpoint; + +/** + * The public {@code Spread} endpoint, returning recent top-of-book spreads by asset pair and a polling cursor. + */ +public class RecentSpreadsEndpoint extends PublicEndpoint { + + /** + * Creates the {@code Spread} endpoint using Kraken's default options. + * + * @param pair the asset pair to query + */ + public RecentSpreadsEndpoint(String pair) { + this(RecentSpreadsParams.builder().pair(pair).build()); + } + + /** + * Creates the {@code Spread} endpoint. + * + * @param params the request parameters + */ + public RecentSpreadsEndpoint(RecentSpreadsParams params) { + super("Spread", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/RecentTradesEndpoint.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/RecentTradesEndpoint.java new file mode 100644 index 0000000..027c5d5 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/RecentTradesEndpoint.java @@ -0,0 +1,31 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import com.fasterxml.jackson.core.type.TypeReference; + +import dev.andstuff.kraken.api.endpoint.market.params.RecentTradesParams; +import dev.andstuff.kraken.api.endpoint.market.response.RecentTrades; +import dev.andstuff.kraken.api.endpoint.pub.PublicEndpoint; + +/** + * The public {@code Trades} endpoint, returning recent trades by asset pair and a polling cursor. + */ +public class RecentTradesEndpoint extends PublicEndpoint { + + /** + * Creates the {@code Trades} endpoint using Kraken's default options. + * + * @param pair the asset pair to query + */ + public RecentTradesEndpoint(String pair) { + this(RecentTradesParams.builder().pair(pair).build()); + } + + /** + * Creates the {@code Trades} endpoint. + * + * @param params the request parameters + */ + public RecentTradesEndpoint(RecentTradesParams params) { + super("Trades", params, new TypeReference<>() {}); + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/GroupedOrderBookParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/GroupedOrderBookParams.java new file mode 100644 index 0000000..dd84e48 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/GroupedOrderBookParams.java @@ -0,0 +1,47 @@ +package dev.andstuff.kraken.api.endpoint.market.params; + +import static dev.andstuff.kraken.api.endpoint.pub.QueryParams.putIfNonNull; + +import java.util.HashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.pub.QueryParams; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of the {@code GroupedBook} endpoint; unset options use Kraken's defaults. + */ +@Getter +@Builder(toBuilder = true) +public class GroupedOrderBookParams implements QueryParams { + + /** + * The asset pair to query, e.g. {@code BTC/USD}. + */ + @NonNull + private final String pair; + + /** + * Price levels per side: 10, 25, 100, 250 or 1000; defaults to 10. + */ + private final Integer depth; + + /** + * Ticks per price level: 1, 5, 10, 25, 50, 100, 250, 500 or 1000; defaults to 1. + */ + private final Integer grouping; + + /** + * {@inheritDoc} + */ + @Override + public Map toMap() { + Map params = new HashMap<>(); + params.put("pair", pair); + putIfNonNull(params, "depth", depth, String::valueOf); + putIfNonNull(params, "grouping", grouping, String::valueOf); + return params; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/Level3OrderBookParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/Level3OrderBookParams.java new file mode 100644 index 0000000..4ff8d3b --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/Level3OrderBookParams.java @@ -0,0 +1,36 @@ +package dev.andstuff.kraken.api.endpoint.market.params; + +import java.util.HashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.priv.PostParams; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of the {@code Level3} endpoint; unset options use Kraken's defaults. + */ +@Getter +@Builder(toBuilder = true) +public class Level3OrderBookParams extends PostParams { + + /** + * The asset pair to query, e.g. {@code BTC/USD}. + */ + @NonNull + private final String pair; + + /** + * Price levels per side: 0 (full book), 10, 25, 100, 250 or 1000; defaults to 100. + */ + private final Integer depth; + + @Override + protected Map params() { + Map params = new HashMap<>(); + params.put("pair", pair); + putIfNonNull(params, "depth", depth); + return params; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/OhlcParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/OhlcParams.java new file mode 100644 index 0000000..602ea66 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/OhlcParams.java @@ -0,0 +1,59 @@ +package dev.andstuff.kraken.api.endpoint.market.params; + +import static dev.andstuff.kraken.api.endpoint.pub.QueryParams.putIfNonNull; + +import java.util.HashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.pub.QueryParams; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of the {@code OHLC} endpoint; unset options use Kraken's defaults. + */ +@Getter +@Builder(toBuilder = true) +public class OhlcParams implements QueryParams { + + /** + * The asset pair to query, e.g. {@code BTC/USD}. + */ + @NonNull + private final String pair; + + /** + * Candle interval in minutes: 1, 5, 15, 30, 60, 240, 1440, 10080 or 21600; defaults to 1. + */ + private final Integer interval; + + /** + * Unix timestamp in seconds, or the last cursor from the previous OHLC response. + */ + private final Long since; + + /** + * Use 1 for display pair names in the response; omitted for internal names. + */ + private final Integer assetVersion; + + /** + * Use tokenized_asset for non-crypto pairs such as xStocks. + */ + private final String assetClass; + + /** + * {@inheritDoc} + */ + @Override + public Map toMap() { + Map params = new HashMap<>(); + params.put("pair", pair); + putIfNonNull(params, "interval", interval, String::valueOf); + putIfNonNull(params, "since", since, String::valueOf); + putIfNonNull(params, "assetVersion", assetVersion, String::valueOf); + putIfNonNull(params, "asset_class", assetClass, v -> v); + return params; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/OrderBookParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/OrderBookParams.java new file mode 100644 index 0000000..54903e2 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/OrderBookParams.java @@ -0,0 +1,53 @@ +package dev.andstuff.kraken.api.endpoint.market.params; + +import static dev.andstuff.kraken.api.endpoint.pub.QueryParams.putIfNonNull; + +import java.util.HashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.pub.QueryParams; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of the {@code Depth} endpoint; unset options use Kraken's defaults. + */ +@Getter +@Builder(toBuilder = true) +public class OrderBookParams implements QueryParams { + + /** + * The asset pair to query, e.g. {@code BTC/USD}. + */ + @NonNull + private final String pair; + + /** + * Maximum entries per side, from 1 to 500; defaults to 100. + */ + private final Integer count; + + /** + * Use 1 for display pair names in the response; omitted for internal names. + */ + private final Integer assetVersion; + + /** + * Use tokenized_asset for non-crypto pairs such as xStocks. + */ + private final String assetClass; + + /** + * {@inheritDoc} + */ + @Override + public Map toMap() { + Map params = new HashMap<>(); + params.put("pair", pair); + putIfNonNull(params, "count", count, String::valueOf); + putIfNonNull(params, "assetVersion", assetVersion, String::valueOf); + putIfNonNull(params, "asset_class", assetClass, v -> v); + return params; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/RecentSpreadsParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/RecentSpreadsParams.java new file mode 100644 index 0000000..7fd5a64 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/RecentSpreadsParams.java @@ -0,0 +1,53 @@ +package dev.andstuff.kraken.api.endpoint.market.params; + +import static dev.andstuff.kraken.api.endpoint.pub.QueryParams.putIfNonNull; + +import java.util.HashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.pub.QueryParams; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of the {@code Spread} endpoint; unset options use Kraken's defaults. + */ +@Getter +@Builder(toBuilder = true) +public class RecentSpreadsParams implements QueryParams { + + /** + * The asset pair to query, e.g. {@code BTC/USD}. + */ + @NonNull + private final String pair; + + /** + * Unix timestamp in seconds, or the last cursor from the previous Spread response. + */ + private final Long since; + + /** + * Use 1 for display pair names in the response; omitted for internal names. + */ + private final Integer assetVersion; + + /** + * Use tokenized_asset for non-crypto pairs such as xStocks. + */ + private final String assetClass; + + /** + * {@inheritDoc} + */ + @Override + public Map toMap() { + Map params = new HashMap<>(); + params.put("pair", pair); + putIfNonNull(params, "since", since, String::valueOf); + putIfNonNull(params, "assetVersion", assetVersion, String::valueOf); + putIfNonNull(params, "asset_class", assetClass, v -> v); + return params; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/RecentTradesParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/RecentTradesParams.java new file mode 100644 index 0000000..4d65a89 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/params/RecentTradesParams.java @@ -0,0 +1,59 @@ +package dev.andstuff.kraken.api.endpoint.market.params; + +import static dev.andstuff.kraken.api.endpoint.pub.QueryParams.putIfNonNull; + +import java.util.HashMap; +import java.util.Map; + +import dev.andstuff.kraken.api.endpoint.pub.QueryParams; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * The parameters of the {@code Trades} endpoint; unset options use Kraken's defaults. + */ +@Getter +@Builder(toBuilder = true) +public class RecentTradesParams implements QueryParams { + + /** + * The asset pair to query, e.g. {@code BTC/USD}. + */ + @NonNull + private final String pair; + + /** + * Timestamp or opaque last cursor from the previous Trades response, preserved verbatim. + */ + private final String since; + + /** + * Maximum number of trades, from 1 to 1000; defaults to 1000. + */ + private final Integer count; + + /** + * Use 1 for display pair names in the response; omitted for internal names. + */ + private final Integer assetVersion; + + /** + * Use tokenized_asset for non-crypto pairs such as xStocks. + */ + private final String assetClass; + + /** + * {@inheritDoc} + */ + @Override + public Map toMap() { + Map params = new HashMap<>(); + params.put("pair", pair); + putIfNonNull(params, "since", since, v -> v); + putIfNonNull(params, "count", count, String::valueOf); + putIfNonNull(params, "assetVersion", assetVersion, String::valueOf); + putIfNonNull(params, "asset_class", assetClass, v -> v); + return params; + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/GroupedOrderBook.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/GroupedOrderBook.java new file mode 100644 index 0000000..9ae4099 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/GroupedOrderBook.java @@ -0,0 +1,25 @@ +package dev.andstuff.kraken.api.endpoint.market.response; + +import java.math.BigDecimal; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The {@code GroupedBook} response, with asks rounded up and bids down to grouped price levels. + * + * @param pair asset pair + * @param grouping ticks per price level used by Kraken + * @param bids aggregated bid levels + * @param asks aggregated ask levels + */ +public record GroupedOrderBook(String pair, int grouping, List bids, List asks) { + + /** + * A grouped price level returned by the {@code GroupedBook} endpoint. + * + * @param price grouped price + * @param quantity aggregated quantity at this price + */ + public record Level(BigDecimal price, @JsonProperty("qty") BigDecimal quantity) {} +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/Level3OrderBook.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/Level3OrderBook.java new file mode 100644 index 0000000..e2b7703 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/Level3OrderBook.java @@ -0,0 +1,41 @@ +package dev.andstuff.kraken.api.endpoint.market.response; + +import java.io.IOException; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; + +/** + * The {@code Level3} response, containing individual orders in the book. + * + * @param pair asset pair + * @param bids bid orders in the order returned by Kraken + * @param asks ask orders in the order returned by Kraken + */ +public record Level3OrderBook(String pair, List bids, List asks) { + + /** + * An individual order returned by the {@code Level3} endpoint. + * + * @param price order price + * @param quantity order quantity + * @param orderId order identifier + * @param timestamp order instant, decoded from epoch nanoseconds + */ + public record Order(BigDecimal price, @JsonProperty("qty") BigDecimal quantity, + @JsonProperty("order_id") String orderId, @JsonDeserialize(using = NanosecondsDeserializer.class) Instant timestamp) {} + + static class NanosecondsDeserializer extends JsonDeserializer { + + @Override + public Instant deserialize(JsonParser parser, DeserializationContext context) throws IOException { + return Instant.ofEpochSecond(0, parser.getLongValue()); + } + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/MaintenanceSchedule.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/MaintenanceSchedule.java new file mode 100644 index 0000000..1c89fee --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/MaintenanceSchedule.java @@ -0,0 +1,75 @@ +package dev.andstuff.kraken.api.endpoint.market.response; + +import java.time.Instant; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * The {@code MaintenanceSchedule} response, covering the next seven days. + * + * @param events scheduled events ordered by expected start time, empty when none are scheduled + */ +public record MaintenanceSchedule(List events) { + + /** + * A scheduled maintenance event returned by the {@code MaintenanceSchedule} endpoint. + * + * @param eventId stable event identifier + * @param title event title + * @param expectedStart expected start time + * @param expectedEnd expected end time + * @param timeToStart seconds until the start, evaluated when Kraken served the response + * @param phase approach phase + * @param affectedServices affected Kraken services + * @param orderSubmission guidance about submitting orders + * @param recommendedAction suggested client action + * @param cancelBefore cancellation deadline, absent when not scheduled + * @param sourceUrl link to the event on Kraken's status page + */ + public record Event(@JsonProperty("event_id") long eventId, + String title, + @JsonProperty("expected_start_utc") Instant expectedStart, + @JsonProperty("expected_end_utc") Instant expectedEnd, + @JsonProperty("time_to_start_s") long timeToStart, + Phase phase, + @JsonProperty("affected_services") List affectedServices, + @JsonProperty("order_submission") OrderSubmission orderSubmission, + @JsonProperty("recommended_action") RecommendedAction recommendedAction, + @JsonProperty("cancel_before_utc") Instant cancelBefore, + @JsonProperty("source_url") String sourceUrl) {} + + /** + * The approach phase in a {@code MaintenanceSchedule} event. + */ + public enum Phase { + ANNOUNCED, REMINDER_24H, APPROACHING_30M, IMMINENT_5M, FINAL_WARNING_30S, + @JsonEnumDefaultValue UNKNOWN + } + + /** + * A Kraken service affected by a {@code MaintenanceSchedule} event. + */ + public enum Service { + SPOT_WS, SPOT_REST, SPOT_FIX, SPOT_TRADING, + FUTURES_WS, FUTURES_REST, FUTURES_FIX, FUTURES_TRADING, ALL, + @JsonEnumDefaultValue UNKNOWN + } + + /** + * Order submission guidance in a {@code MaintenanceSchedule} event. + */ + public enum OrderSubmission { + ALLOWED, DISCOURAGED, BLOCKED, + @JsonEnumDefaultValue UNKNOWN + } + + /** + * Suggested client action in a {@code MaintenanceSchedule} event. + */ + public enum RecommendedAction { + CONTINUE, REDUCE_ACTIVITY, CANCEL_OPEN_ORDERS, STOP_NEW_ORDERS, + @JsonEnumDefaultValue UNKNOWN + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/OhlcData.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/OhlcData.java new file mode 100644 index 0000000..6e1f9a8 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/OhlcData.java @@ -0,0 +1,62 @@ +package dev.andstuff.kraken.api.endpoint.market.response; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; + +/** + * The {@code OHLC} response containing OHLC candles, including the final, uncommitted candle. At most 720 entries are available. + * + * @param candles entries by Kraken's returned pair name (internal or display name) + * @param last cursor to pass unchanged as {@code since} when polling for committed candles + */ +@JsonDeserialize(builder = OhlcData.ResponseBuilder.class) +public record OhlcData(Map> candles, Long last) { + + /** + * A positional OHLC candle returned by the {@code OHLC} endpoint. + * + * @param time candle start instant + * @param open opening price + * @param high highest price + * @param low lowest price + * @param close closing price + * @param vwap volume weighted average price + * @param volume traded volume + * @param count number of trades + */ + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + @JsonPropertyOrder({"time", "open", "high", "low", "close", "vwap", "volume", "count"}) + public record Candle(Instant time, BigDecimal open, BigDecimal high, BigDecimal low, + BigDecimal close, BigDecimal vwap, BigDecimal volume, long count) {} + + @JsonPOJOBuilder(withPrefix = "") + static class ResponseBuilder { + + private final Map> entries = new LinkedHashMap<>(); + private Long last; + + @JsonProperty("last") + void last(Long value) { + last = value; + } + + @JsonAnySetter + void pair(String name, List values) { + entries.put(name, values); + } + + OhlcData build() { + return new OhlcData(entries, last); + } + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/OrderBook.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/OrderBook.java new file mode 100644 index 0000000..87b8648 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/OrderBook.java @@ -0,0 +1,28 @@ +package dev.andstuff.kraken.api.endpoint.market.response; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +/** + * A pair's aggregated L2 order book from the {@code Depth} endpoint. + * + * @param asks ask levels in the order returned by Kraken + * @param bids bid levels in the order returned by Kraken + */ +public record OrderBook(List asks, List bids) { + + /** + * An aggregated price level returned by the {@code Depth} endpoint. + * + * @param price price of this level + * @param volume aggregated volume at this price + * @param time timestamp as an instant + */ + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + @JsonPropertyOrder({"price", "volume", "time"}) + public record Level(BigDecimal price, BigDecimal volume, Instant time) {} +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/RecentSpreads.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/RecentSpreads.java new file mode 100644 index 0000000..f6e5879 --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/RecentSpreads.java @@ -0,0 +1,56 @@ +package dev.andstuff.kraken.api.endpoint.market.response; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; + +/** + * The {@code Spread} response containing recent top-of-book spreads; Kraken retains approximately 200 entries. + * + * @param spreads entries by Kraken's returned pair name (internal or display name) + * @param last cursor to pass unchanged as {@code since} when polling for new data + */ +@JsonDeserialize(builder = RecentSpreads.ResponseBuilder.class) +public record RecentSpreads(Map> spreads, Long last) { + + /** + * A positional top-of-book spread returned by the {@code Spread} endpoint. + * + * @param time timestamp as an instant + * @param bid best bid price + * @param ask best ask price + */ + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + @JsonPropertyOrder({"time", "bid", "ask"}) + public record Spread(Instant time, BigDecimal bid, BigDecimal ask) {} + + @JsonPOJOBuilder(withPrefix = "") + static class ResponseBuilder { + + private final Map> entries = new LinkedHashMap<>(); + private Long last; + + @JsonProperty("last") + void last(Long value) { + last = value; + } + + @JsonAnySetter + void pair(String name, List values) { + entries.put(name, values); + } + + RecentSpreads build() { + return new RecentSpreads(entries, last); + } + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/RecentTrades.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/RecentTrades.java new file mode 100644 index 0000000..0059bef --- /dev/null +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/RecentTrades.java @@ -0,0 +1,80 @@ +package dev.andstuff.kraken.api.endpoint.market.response; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder; + +/** + * The {@code Trades} response containing recent trades; by default Kraken returns the last 1000 trades. + * + * @param trades entries by Kraken's returned pair name (internal or display name) + * @param last cursor to pass unchanged as {@code since} when polling for new data + */ +@JsonDeserialize(builder = RecentTrades.ResponseBuilder.class) +public record RecentTrades(Map> trades, String last) { + + /** + * A positional trade returned by the {@code Trades} endpoint. + * + * @param price trade price + * @param volume traded volume + * @param time trade instant, retaining nanosecond precision + * @param side buy or sell + * @param orderType market or limit + * @param miscellaneous additional trade information + * @param tradeId trade identifier + */ + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + @JsonPropertyOrder({"price", "volume", "time", "side", "orderType", "miscellaneous", "tradeId"}) + public record Trade(BigDecimal price, BigDecimal volume, Instant time, Side side, + OrderType orderType, String miscellaneous, long tradeId) {} + + /** + * The buy/sell code in a {@code Trades} row. + */ + public enum Side { + @JsonProperty("b") BUY, + @JsonProperty("s") SELL, + @JsonEnumDefaultValue UNKNOWN + } + + /** + * The market/limit code in a {@code Trades} row. + */ + public enum OrderType { + @JsonProperty("m") MARKET, + @JsonProperty("l") LIMIT, + @JsonEnumDefaultValue UNKNOWN + } + + @JsonPOJOBuilder(withPrefix = "") + static class ResponseBuilder { + + private final Map> entries = new LinkedHashMap<>(); + private String last; + + @JsonProperty("last") + void last(String value) { + last = value; + } + + @JsonAnySetter + void pair(String name, List values) { + entries.put(name, values); + } + + RecentTrades build() { + return new RecentTrades(entries, last); + } + } +} diff --git a/library/src/main/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequester.java b/library/src/main/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequester.java index efc4f55..5fbd8de 100644 --- a/library/src/main/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequester.java +++ b/library/src/main/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequester.java @@ -2,8 +2,8 @@ import java.io.IOException; import java.io.OutputStreamWriter; +import java.net.URL; import java.util.zip.ZipInputStream; - import javax.net.ssl.HttpsURLConnection; import com.fasterxml.jackson.databind.DeserializationFeature; @@ -40,6 +40,21 @@ public class DefaultKrakenRestRequester implements KrakenRestRequester { .build(); } + private final ConnectionFactory connectionFactory; + + public DefaultKrakenRestRequester() { + this(url -> (HttpsURLConnection) url.openConnection()); + } + + DefaultKrakenRestRequester(ConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + } + + @FunctionalInterface + interface ConnectionFactory { + HttpsURLConnection open(URL url) throws IOException; + } + /** * {@inheritDoc} * @@ -85,8 +100,8 @@ public T execute(PrivateEndpoint endpoint, KrakenCredentials credentials, } } - private static HttpsURLConnection createHttpsConnection(Endpoint endpoint) throws IOException { - HttpsURLConnection connection = (HttpsURLConnection) endpoint.buildURL().openConnection(); + private HttpsURLConnection createHttpsConnection(Endpoint endpoint) throws IOException { + HttpsURLConnection connection = connectionFactory.open(endpoint.buildURL()); connection.setRequestMethod(endpoint.getHttpMethod()); connection.addRequestProperty("User-Agent", "github.com/nyg/kraken-api-java"); return connection; diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/GroupedOrderBookEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/GroupedOrderBookEndpointTest.java new file mode 100644 index 0000000..6380fbc --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/GroupedOrderBookEndpointTest.java @@ -0,0 +1,113 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.KrakenAPI; +import dev.andstuff.kraken.api.endpoint.KrakenResponse; +import dev.andstuff.kraken.api.endpoint.market.params.GroupedOrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.response.GroupedOrderBook; + +@ExtendWith(MockitoExtension.class) +class GroupedOrderBookEndpointTest { + + @Test + void should_encode_all_documented_fields_when_all_options_are_set() { + GroupedOrderBookEndpoint unit = new GroupedOrderBookEndpoint(GroupedOrderBookParams.builder().pair("BTC/USD").depth(25).grouping(1000).build()); + + URL url = unit.buildURL(); + Map parameters = Arrays.stream(url.getQuery().split("&")) + .map(entry -> entry.split("=", 2)) + .collect(Collectors.toMap(entry -> URLDecoder.decode(entry[0], StandardCharsets.UTF_8), + entry -> URLDecoder.decode(entry[1], StandardCharsets.UTF_8))); + + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + assertThat(url).hasProtocol("https").hasHost("api.kraken.com").hasPath("/0/public/GroupedBook"); + assertThat(parameters).containsExactlyInAnyOrderEntriesOf(Map.of("pair", "BTC/USD", "depth", "25", "grouping", "1000")); + } + + @Test + void should_omit_optional_parameters_when_only_the_pair_is_provided() { + GroupedOrderBookEndpoint unit = new GroupedOrderBookEndpoint("BTC/USD"); + + URL result = unit.buildURL(); + + assertThat(result).hasParameter("pair", "BTC/USD"); + assertThat(result.getQuery()).isEqualTo("pair=BTC%2FUSD"); + } + + @Test + void should_reject_missing_pair_when_building_parameters() { + GroupedOrderBookParams.GroupedOrderBookParamsBuilder unit = GroupedOrderBookParams.builder(); + + assertThatThrownBy(unit::build).isInstanceOf(NullPointerException.class).hasMessageContaining("pair"); + } + + @Test + void should_read_grouping_and_aggregated_quantities_when_decoding_documented_response() throws Exception { + GroupedOrderBookEndpoint unit = new GroupedOrderBookEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/grouped-book.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + GroupedOrderBook result = response.result().orElseThrow(); + + assertThat(result).extracting(GroupedOrderBook::pair, GroupedOrderBook::grouping).containsExactly("BTC/USD", 1000); + assertThat(result.bids()).hasSize(2).first() + .isEqualTo(new GroupedOrderBook.Level(new BigDecimal("90400.00000"), new BigDecimal("19.83057746"))); + assertThat(result.asks()).hasSize(2).first() + .isEqualTo(new GroupedOrderBook.Level(new BigDecimal("90500.00000"), new BigDecimal("38.96185061"))); + } + + @Test + void should_return_empty_collections_when_no_entries_are_available() throws Exception { + GroupedOrderBookEndpoint unit = new GroupedOrderBookEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json = """ + {"error":[],"result":{"pair":"BTC/USD","grouping":1,"asks":[],"bids":[]}} + """; + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + GroupedOrderBook result = response.result().orElseThrow(); + + assertThat(result.asks()).isEmpty(); + assertThat(result.bids()).isEmpty(); + } + @Test + void should_expose_endpoint_path_when_listing_api_endpoints() { + assertThat(Arrays.asList(KrakenAPI.Public.values())).extracting(KrakenAPI.Public::getPath).contains("GroupedBook"); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/Level3OrderBookEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/Level3OrderBookEndpointTest.java new file mode 100644 index 0000000..bd79f44 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/Level3OrderBookEndpointTest.java @@ -0,0 +1,130 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.KrakenAPI; +import dev.andstuff.kraken.api.endpoint.KrakenResponse; +import dev.andstuff.kraken.api.endpoint.market.params.Level3OrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.response.Level3OrderBook; + +@ExtendWith(MockitoExtension.class) +class Level3OrderBookEndpointTest { + + @Test + void should_encode_all_documented_fields_when_all_options_are_set() { + Level3OrderBookEndpoint unit = new Level3OrderBookEndpoint(Level3OrderBookParams.builder().pair("YFI/EUR").depth(0).build()); + + URL url = unit.buildURL(); + Map parameters = Arrays.stream(unit.encodedParamsWith("123").split("&")) + .map(entry -> entry.split("=", 2)) + .collect(Collectors.toMap(entry -> URLDecoder.decode(entry[0], StandardCharsets.UTF_8), + entry -> URLDecoder.decode(entry[1], StandardCharsets.UTF_8))); + + assertThat(unit.getHttpMethod()).isEqualTo("POST"); + assertThat(url).hasProtocol("https").hasHost("api.kraken.com").hasPath("/0/private/Level3"); + assertThat(parameters).containsExactlyInAnyOrderEntriesOf(Map.of("pair", "YFI/EUR", "depth", "0", "nonce", "123")); + } + + @Test + void should_omit_optional_parameters_when_only_the_pair_is_provided() { + Level3OrderBookEndpoint unit = new Level3OrderBookEndpoint("BTC/USD"); + + Map parameters = Arrays.stream(unit.encodedParamsWith("123").split("&")) + .map(entry -> entry.split("=", 2)) + .collect(Collectors.toMap(entry -> URLDecoder.decode(entry[0], StandardCharsets.UTF_8), + entry -> URLDecoder.decode(entry[1], StandardCharsets.UTF_8))); + + assertThat(parameters).containsExactlyInAnyOrderEntriesOf(Map.of("pair", "BTC/USD", "nonce", "123")); + } + + @Test + void should_reject_missing_pair_when_building_parameters() { + Level3OrderBookParams.Level3OrderBookParamsBuilder unit = Level3OrderBookParams.builder(); + + assertThatThrownBy(unit::build).isInstanceOf(NullPointerException.class).hasMessageContaining("pair"); + } + + @Test + void should_retain_individual_orders_and_nanosecond_timestamps_when_decoding_documented_response() throws Exception { + Level3OrderBookEndpoint unit = new Level3OrderBookEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/level3.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + Level3OrderBook result = response.result().orElseThrow(); + + assertThat(result.pair()).isEqualTo("YFI/EUR"); + assertThat(result.bids()).hasSize(2).first().isEqualTo( + new Level3OrderBook.Order(new BigDecimal("3062.00000"), new BigDecimal("0.29665800"), "O5KJU4-IEQTM-NDMS6W", Instant.ofEpochSecond(0, 1765622008594292000L))); + assertThat(result.bids().getLast().orderId()).isNotEqualTo(result.bids().getFirst().orderId()); + assertThat(result.asks().getFirst().quantity()).isEqualTo(new BigDecimal("0.00278335")); + assertThat(result.asks().getLast().timestamp()).isEqualTo(Instant.ofEpochSecond(0, 1765622021013826600L)); + } + + @Test + void should_return_empty_collections_when_no_entries_are_available() throws Exception { + Level3OrderBookEndpoint unit = new Level3OrderBookEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json = """ + {"error":[],"result":{"pair":"BTC/USD","asks":[],"bids":[]}} + """; + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + Level3OrderBook result = response.result().orElseThrow(); + + assertThat(result.asks()).isEmpty(); + assertThat(result.bids()).isEmpty(); + } + + @Test + void should_replace_previous_nonce_when_reusing_endpoint() { + Level3OrderBookEndpoint unit = new Level3OrderBookEndpoint("YFI/EUR"); + unit.encodedParamsWith("123"); + + Map parameters = Arrays.stream(unit.encodedParamsWith("124").split("&")) + .map(entry -> entry.split("=", 2)) + .collect(Collectors.toMap(entry -> URLDecoder.decode(entry[0], StandardCharsets.UTF_8), + entry -> URLDecoder.decode(entry[1], StandardCharsets.UTF_8))); + + assertThat(parameters).containsExactlyInAnyOrderEntriesOf(Map.of("pair", "YFI/EUR", "nonce", "124")); + } + @Test + void should_expose_endpoint_path_when_listing_api_endpoints() { + assertThat(Arrays.asList(KrakenAPI.Private.values())).extracting(KrakenAPI.Private::getPath).contains("Level3"); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/MaintenanceScheduleEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/MaintenanceScheduleEndpointTest.java new file mode 100644 index 0000000..f011f4b --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/MaintenanceScheduleEndpointTest.java @@ -0,0 +1,110 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.KrakenAPI; +import dev.andstuff.kraken.api.endpoint.KrakenResponse; +import dev.andstuff.kraken.api.endpoint.market.response.MaintenanceSchedule; + +@ExtendWith(MockitoExtension.class) +class MaintenanceScheduleEndpointTest { + + @InjectMocks + private MaintenanceScheduleEndpoint unit; + + @Test + void should_use_public_get_without_parameters_when_requesting_the_schedule() { + URL result = unit.buildURL(); + + assertThat(result).hasProtocol("https").hasHost("api.kraken.com").hasPath("/0/public/MaintenanceSchedule").hasNoParameters(); + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + assertThat(KrakenAPI.Public.MAINTENANCE_SCHEDULE.getPath()).isEqualTo("MaintenanceSchedule"); + } + + @Test + void should_read_every_maintenance_event_field_when_decoding_documented_response() throws Exception { + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/maintenance-schedule.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + MaintenanceSchedule result = response.result().orElseThrow(); + + assertThat(result.events()).containsExactly(new MaintenanceSchedule.Event(21, "Scheduled Maintenance - Website", + Instant.parse("2026-05-11T09:00:00Z"), Instant.parse("2026-05-11T10:00:00Z"), 1740, + MaintenanceSchedule.Phase.APPROACHING_30M, List.of(MaintenanceSchedule.Service.SPOT_TRADING), + MaintenanceSchedule.OrderSubmission.ALLOWED, MaintenanceSchedule.RecommendedAction.REDUCE_ACTIVITY, + Instant.parse("2026-05-11T08:55:00Z"), "https://status.kraken.com/incidents/b7k2r9wqmn41")); + } + + @Test + void should_return_empty_collections_when_no_entries_are_available() throws Exception { + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json = """ + {"error":[],"result":{"events":[]}} + """; + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + MaintenanceSchedule result = response.result().orElseThrow(); + + assertThat(result.events()).isEmpty(); + } + + @Test + void should_retain_unknown_enums_and_absent_deadline_when_schedule_gains_new_values() throws Exception { + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/maintenance-schedule.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8).replace("approaching_30m", "future-phase") + .replace("spot_trading", "future-service").replace("allowed", "future-guidance") + .replace("reduce_activity", "future-action") + .replace("\"cancel_before_utc\": \"2026-05-11T08:55:00Z\",", "\"future_field\": true,"); + } + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + MaintenanceSchedule result = response.result().orElseThrow(); + + assertThat(result.events().getFirst()).satisfies(event -> { + assertThat(event.phase()).isEqualTo(MaintenanceSchedule.Phase.UNKNOWN); + assertThat(event.affectedServices()).containsExactly(MaintenanceSchedule.Service.UNKNOWN); + assertThat(event.orderSubmission()).isEqualTo(MaintenanceSchedule.OrderSubmission.UNKNOWN); + assertThat(event.recommendedAction()).isEqualTo(MaintenanceSchedule.RecommendedAction.UNKNOWN); + assertThat(event.cancelBefore()).isNull(); + }); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/MarketDataTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/MarketDataTest.java new file mode 100644 index 0000000..2069183 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/MarketDataTest.java @@ -0,0 +1,263 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import dev.andstuff.kraken.api.KrakenAPI; +import dev.andstuff.kraken.api.endpoint.KrakenException; +import dev.andstuff.kraken.api.endpoint.market.params.GroupedOrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.params.Level3OrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.params.OhlcParams; +import dev.andstuff.kraken.api.endpoint.market.params.OrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.params.RecentSpreadsParams; +import dev.andstuff.kraken.api.endpoint.market.params.RecentTradesParams; +import dev.andstuff.kraken.api.endpoint.market.response.GroupedOrderBook; +import dev.andstuff.kraken.api.endpoint.market.response.Level3OrderBook; +import dev.andstuff.kraken.api.endpoint.market.response.MaintenanceSchedule; +import dev.andstuff.kraken.api.endpoint.market.response.OhlcData; +import dev.andstuff.kraken.api.endpoint.market.response.OrderBook; +import dev.andstuff.kraken.api.endpoint.market.response.RecentSpreads; +import dev.andstuff.kraken.api.endpoint.market.response.RecentTrades; +import dev.andstuff.kraken.api.rest.KrakenCredentials; +import dev.andstuff.kraken.api.rest.KrakenNonceGenerator; +import dev.andstuff.kraken.api.rest.KrakenRestRequester; + +@ExtendWith(MockitoExtension.class) +class MarketDataTest { + + @Mock + private KrakenRestRequester requester; + + @Mock + private KrakenCredentials credentials; + + @Mock + private KrakenNonceGenerator nonceGenerator; + + @Test + void should_route_ohlc_through_configured_requester_when_using_default_options() { + KrakenAPI unit = new KrakenAPI(null, requester); + OhlcData expected = new OhlcData(Map.of(), 123L); + when(requester.execute(any(OhlcEndpoint.class))).thenReturn(expected); + + OhlcData result = unit.ohlc("BTC/USD"); + + assertThat(result).isSameAs(expected); + verify(requester).execute(argThat((OhlcEndpoint endpoint) -> endpoint.buildURL().getQuery().equals("pair=BTC%2FUSD"))); + } + + @Test + void should_route_ohlc_through_configured_requester_when_using_custom_options() { + KrakenAPI unit = new KrakenAPI(null, requester); + OhlcParams params = OhlcParams.builder().pair("BTC/USD").interval(60).build(); + OhlcData expected = new OhlcData(Map.of(), 123L); + when(requester.execute(any(OhlcEndpoint.class))).thenReturn(expected); + + OhlcData result = unit.ohlc(params); + + assertThat(result).isSameAs(expected); + verify(requester).execute(argThat((OhlcEndpoint endpoint) -> Arrays.stream(endpoint.buildURL().getQuery().split("&")) + .map(entry -> entry.split("=", 2)) + .collect(Collectors.toMap(entry -> entry[0], entry -> URLDecoder.decode(entry[1], StandardCharsets.UTF_8))) + .equals(params.toMap()))); + } + + @Test + void should_route_order_book_through_configured_requester_when_using_default_options() { + KrakenAPI unit = new KrakenAPI(null, requester); + Map expected = Map.of("BTC/USD", new OrderBook(List.of(), List.of())); + when(requester.execute(any(OrderBookEndpoint.class))).thenReturn(expected); + + Map result = unit.orderBook("BTC/USD"); + + assertThat(result).isSameAs(expected); + verify(requester).execute(argThat((OrderBookEndpoint endpoint) -> endpoint.buildURL().getQuery().equals("pair=BTC%2FUSD"))); + } + + @Test + void should_route_order_book_through_configured_requester_when_using_custom_options() { + KrakenAPI unit = new KrakenAPI(null, requester); + OrderBookParams params = OrderBookParams.builder().pair("BTC/USD").count(10).build(); + Map expected = Map.of("BTC/USD", new OrderBook(List.of(), List.of())); + when(requester.execute(any(OrderBookEndpoint.class))).thenReturn(expected); + + Map result = unit.orderBook(params); + + assertThat(result).isSameAs(expected); + verify(requester).execute(argThat((OrderBookEndpoint endpoint) -> Arrays.stream(endpoint.buildURL().getQuery().split("&")) + .map(entry -> entry.split("=", 2)) + .collect(Collectors.toMap(entry -> entry[0], entry -> URLDecoder.decode(entry[1], StandardCharsets.UTF_8))) + .equals(params.toMap()))); + } + + @Test + void should_route_recent_trades_through_configured_requester_when_using_default_options() { + KrakenAPI unit = new KrakenAPI(null, requester); + RecentTrades expected = new RecentTrades(Map.of(), "123"); + when(requester.execute(any(RecentTradesEndpoint.class))).thenReturn(expected); + + RecentTrades result = unit.recentTrades("BTC/USD"); + + assertThat(result).isSameAs(expected); + verify(requester).execute(argThat((RecentTradesEndpoint endpoint) -> endpoint.buildURL().getQuery().equals("pair=BTC%2FUSD"))); + } + + @Test + void should_route_recent_trades_through_configured_requester_when_using_custom_options() { + KrakenAPI unit = new KrakenAPI(null, requester); + RecentTradesParams params = RecentTradesParams.builder().pair("BTC/USD").count(10).build(); + RecentTrades expected = new RecentTrades(Map.of(), "123"); + when(requester.execute(any(RecentTradesEndpoint.class))).thenReturn(expected); + + RecentTrades result = unit.recentTrades(params); + + assertThat(result).isSameAs(expected); + verify(requester).execute(argThat((RecentTradesEndpoint endpoint) -> Arrays.stream(endpoint.buildURL().getQuery().split("&")) + .map(entry -> entry.split("=", 2)) + .collect(Collectors.toMap(entry -> entry[0], entry -> URLDecoder.decode(entry[1], StandardCharsets.UTF_8))) + .equals(params.toMap()))); + } + + @Test + void should_route_recent_spreads_through_configured_requester_when_using_default_options() { + KrakenAPI unit = new KrakenAPI(null, requester); + RecentSpreads expected = new RecentSpreads(Map.of(), 123L); + when(requester.execute(any(RecentSpreadsEndpoint.class))).thenReturn(expected); + + RecentSpreads result = unit.recentSpreads("BTC/USD"); + + assertThat(result).isSameAs(expected); + verify(requester).execute(argThat((RecentSpreadsEndpoint endpoint) -> endpoint.buildURL().getQuery().equals("pair=BTC%2FUSD"))); + } + + @Test + void should_route_recent_spreads_through_configured_requester_when_using_custom_options() { + KrakenAPI unit = new KrakenAPI(null, requester); + RecentSpreadsParams params = RecentSpreadsParams.builder().pair("BTC/USD").since(123L).build(); + RecentSpreads expected = new RecentSpreads(Map.of(), 123L); + when(requester.execute(any(RecentSpreadsEndpoint.class))).thenReturn(expected); + + RecentSpreads result = unit.recentSpreads(params); + + assertThat(result).isSameAs(expected); + verify(requester).execute(argThat((RecentSpreadsEndpoint endpoint) -> Arrays.stream(endpoint.buildURL().getQuery().split("&")) + .map(entry -> entry.split("=", 2)) + .collect(Collectors.toMap(entry -> entry[0], entry -> URLDecoder.decode(entry[1], StandardCharsets.UTF_8))) + .equals(params.toMap()))); + } + + @Test + void should_route_grouped_order_book_through_configured_requester_when_using_default_options() { + KrakenAPI unit = new KrakenAPI(null, requester); + GroupedOrderBook expected = new GroupedOrderBook("BTC/USD", 1000, List.of(), List.of()); + when(requester.execute(any(GroupedOrderBookEndpoint.class))).thenReturn(expected); + + GroupedOrderBook result = unit.groupedOrderBook("BTC/USD"); + + assertThat(result).isSameAs(expected); + verify(requester).execute(argThat((GroupedOrderBookEndpoint endpoint) -> endpoint.buildURL().getQuery().equals("pair=BTC%2FUSD"))); + } + + @Test + void should_route_grouped_order_book_through_configured_requester_when_using_custom_options() { + KrakenAPI unit = new KrakenAPI(null, requester); + GroupedOrderBookParams params = GroupedOrderBookParams.builder().pair("BTC/USD").grouping(1000).build(); + GroupedOrderBook expected = new GroupedOrderBook("BTC/USD", 1000, List.of(), List.of()); + when(requester.execute(any(GroupedOrderBookEndpoint.class))).thenReturn(expected); + + GroupedOrderBook result = unit.groupedOrderBook(params); + + assertThat(result).isSameAs(expected); + verify(requester).execute(argThat((GroupedOrderBookEndpoint endpoint) -> Arrays.stream(endpoint.buildURL().getQuery().split("&")) + .map(entry -> entry.split("=", 2)) + .collect(Collectors.toMap(entry -> entry[0], entry -> URLDecoder.decode(entry[1], StandardCharsets.UTF_8))) + .equals(params.toMap()))); + } + + @Test + void should_route_maintenance_schedule_through_configured_requester_when_querying_events() { + KrakenAPI unit = new KrakenAPI(null, requester); + MaintenanceSchedule expected = new MaintenanceSchedule(List.of()); + when(requester.execute(any(MaintenanceScheduleEndpoint.class))).thenReturn(expected); + + MaintenanceSchedule result = unit.maintenanceSchedule(); + + assertThat(result).isSameAs(expected); + verify(requester).execute(any(MaintenanceScheduleEndpoint.class)); + } + + @Test + void should_pass_credentials_and_nonce_generator_when_querying_level3_with_default_options() { + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + Level3OrderBook expected = new Level3OrderBook("YFI/EUR", List.of(), List.of()); + when(requester.execute(any(Level3OrderBookEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(expected); + + Level3OrderBook result = unit.level3OrderBook("YFI/EUR"); + + assertThat(result).isSameAs(expected); + verify(requester).execute(argThat((Level3OrderBookEndpoint endpoint) -> { + Level3OrderBookParams params = (Level3OrderBookParams) endpoint.getPostParams(); + return params.getPair().equals("YFI/EUR") && params.getDepth() == null; + }), same(credentials), same(nonceGenerator)); + } + + @Test + void should_preserve_parameters_and_authentication_when_querying_level3_with_custom_options() { + KrakenAPI unit = new KrakenAPI(credentials, nonceGenerator, requester); + Level3OrderBookParams params = Level3OrderBookParams.builder().pair("YFI/EUR").depth(0).build(); + Level3OrderBook expected = new Level3OrderBook("YFI/EUR", List.of(), List.of()); + when(requester.execute(any(Level3OrderBookEndpoint.class), same(credentials), same(nonceGenerator))).thenReturn(expected); + + Level3OrderBook result = unit.level3OrderBook(params); + + assertThat(result).isSameAs(expected); + verify(requester).execute(argThat((Level3OrderBookEndpoint endpoint) -> endpoint.getPostParams() == params), same(credentials), same(nonceGenerator)); + } + + @Test + void should_reject_level3_before_requesting_when_credentials_are_absent() { + KrakenAPI unit = new KrakenAPI(null, requester); + + assertThatThrownBy(() -> unit.level3OrderBook("BTC/USD")) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("Level3"); + verifyNoInteractions(requester); + } + + @Test + void should_reject_custom_level3_before_requesting_when_credentials_are_absent() { + KrakenAPI unit = new KrakenAPI(null, requester); + Level3OrderBookParams params = Level3OrderBookParams.builder().pair("BTC/USD").depth(10).build(); + + assertThatThrownBy(() -> unit.level3OrderBook(params)) + .isInstanceOf(IllegalStateException.class).hasMessageContaining("Level3"); + verifyNoInteractions(requester); + } + + @Test + void should_propagate_kraken_error_when_requester_rejects_pair() { + KrakenAPI unit = new KrakenAPI(null, requester); + KrakenException error = new KrakenException(List.of("EQuery:Unknown asset pair")); + when(requester.execute(any(OhlcEndpoint.class))).thenThrow(error); + + assertThatThrownBy(() -> unit.ohlc("BTC/USD")).isSameAs(error); + verify(requester).execute(any(OhlcEndpoint.class)); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/OhlcEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/OhlcEndpointTest.java new file mode 100644 index 0000000..536d90b --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/OhlcEndpointTest.java @@ -0,0 +1,155 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.KrakenAPI; +import dev.andstuff.kraken.api.endpoint.KrakenResponse; +import dev.andstuff.kraken.api.endpoint.market.params.OhlcParams; +import dev.andstuff.kraken.api.endpoint.market.response.OhlcData; + +@ExtendWith(MockitoExtension.class) +class OhlcEndpointTest { + + @Test + void should_encode_all_documented_fields_when_all_options_are_set() { + OhlcEndpoint unit = new OhlcEndpoint(OhlcParams.builder().pair("BTC/USD").interval(60).since(1688671200L).assetVersion(1).assetClass("tokenized_asset").build()); + + URL url = unit.buildURL(); + Map parameters = Arrays.stream(url.getQuery().split("&")) + .map(entry -> entry.split("=", 2)) + .collect(Collectors.toMap(entry -> URLDecoder.decode(entry[0], StandardCharsets.UTF_8), + entry -> URLDecoder.decode(entry[1], StandardCharsets.UTF_8))); + + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + assertThat(url).hasProtocol("https").hasHost("api.kraken.com").hasPath("/0/public/OHLC"); + assertThat(parameters).containsExactlyInAnyOrderEntriesOf(Map.of("pair", "BTC/USD", "interval", "60", "since", "1688671200", "assetVersion", "1", "asset_class", "tokenized_asset")); + } + + @Test + void should_omit_optional_parameters_when_only_the_pair_is_provided() { + OhlcEndpoint unit = new OhlcEndpoint("BTC/USD"); + + URL result = unit.buildURL(); + + assertThat(result).hasParameter("pair", "BTC/USD"); + assertThat(result.getQuery()).isEqualTo("pair=BTC%2FUSD"); + } + + @Test + void should_reject_missing_pair_when_building_parameters() { + OhlcParams.OhlcParamsBuilder unit = OhlcParams.builder(); + + assertThatThrownBy(unit::build).isInstanceOf(NullPointerException.class).hasMessageContaining("pair"); + } + + @Test + void should_retain_all_candle_positions_and_committed_cursor_when_decoding_documented_response() throws Exception { + OhlcEndpoint unit = new OhlcEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/ohlc.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + OhlcData result = response.result().orElseThrow(); + + assertThat(result.candles().get("XXBTZUSD")).hasSize(2).first().isEqualTo( + new OhlcData.Candle(Instant.ofEpochSecond(1688671200), new BigDecimal("30306.1"), new BigDecimal("30306.2"), new BigDecimal("30305.7"), + new BigDecimal("30305.7"), new BigDecimal("30306.1"), new BigDecimal("3.39243896"), 23)); + assertThat(result.candles().get("XXBTZUSD").getLast().time()).isEqualTo(Instant.ofEpochSecond(1688671260L)); + assertThat(result.last()).isEqualTo(1688672160L); + assertThat(OhlcParams.builder().pair("BTC/USD").since(result.last()).build().toMap()).containsEntry("since", "1688672160"); + } + + @Test + void should_retain_display_pair_keys_when_asset_version_is_enabled() throws Exception { + OhlcEndpoint unit = new OhlcEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/ohlc.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8).replace("XXBTZUSD", "BTC/USD"); + } + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + OhlcData result = response.result().orElseThrow(); + + assertThat(result.candles()).containsOnlyKeys("BTC/USD"); + } + + @Test + void should_separate_cursor_from_pair_entries_when_cursor_precedes_multiple_pairs() throws Exception { + OhlcEndpoint unit = new OhlcEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json = """ + {"error":[],"result":{"last":123,"BTC/USD":[],"ETH/USD":[]}} + """; + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + OhlcData result = response.result().orElseThrow(); + + assertThat(result.candles()).containsExactlyInAnyOrderEntriesOf(Map.of("BTC/USD", List.of(), "ETH/USD", List.of())); + assertThat(result.last()).isEqualTo(123L); + } + + @Test + void should_return_empty_collections_when_no_entries_are_available() throws Exception { + OhlcEndpoint unit = new OhlcEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json = """ + {"error":[],"result":{"BTC/USD":[],"last":123}} + """; + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + OhlcData result = response.result().orElseThrow(); + + assertThat(result.candles().get("BTC/USD")).isEmpty(); + } + @Test + void should_expose_endpoint_path_when_listing_api_endpoints() { + assertThat(Arrays.asList(KrakenAPI.Public.values())).extracting(KrakenAPI.Public::getPath).contains("OHLC"); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/OrderBookEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/OrderBookEndpointTest.java new file mode 100644 index 0000000..3b24de1 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/OrderBookEndpointTest.java @@ -0,0 +1,133 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.KrakenAPI; +import dev.andstuff.kraken.api.endpoint.KrakenResponse; +import dev.andstuff.kraken.api.endpoint.market.params.OrderBookParams; +import dev.andstuff.kraken.api.endpoint.market.response.OrderBook; + +@ExtendWith(MockitoExtension.class) +class OrderBookEndpointTest { + + @Test + void should_encode_all_documented_fields_when_all_options_are_set() { + OrderBookEndpoint unit = new OrderBookEndpoint(OrderBookParams.builder().pair("BTC/USD").count(500).assetVersion(1).assetClass("tokenized_asset").build()); + + URL url = unit.buildURL(); + Map parameters = Arrays.stream(url.getQuery().split("&")) + .map(entry -> entry.split("=", 2)) + .collect(Collectors.toMap(entry -> URLDecoder.decode(entry[0], StandardCharsets.UTF_8), + entry -> URLDecoder.decode(entry[1], StandardCharsets.UTF_8))); + + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + assertThat(url).hasProtocol("https").hasHost("api.kraken.com").hasPath("/0/public/Depth"); + assertThat(parameters).containsExactlyInAnyOrderEntriesOf(Map.of("pair", "BTC/USD", "count", "500", "assetVersion", "1", "asset_class", "tokenized_asset")); + } + + @Test + void should_omit_optional_parameters_when_only_the_pair_is_provided() { + OrderBookEndpoint unit = new OrderBookEndpoint("BTC/USD"); + + URL result = unit.buildURL(); + + assertThat(result).hasParameter("pair", "BTC/USD"); + assertThat(result.getQuery()).isEqualTo("pair=BTC%2FUSD"); + } + + @Test + void should_reject_missing_pair_when_building_parameters() { + OrderBookParams.OrderBookParamsBuilder unit = OrderBookParams.builder(); + + assertThatThrownBy(unit::build).isInstanceOf(NullPointerException.class).hasMessageContaining("pair"); + } + + @Test + void should_read_levels_and_ignore_unknown_properties_when_decoding_documented_response() throws Exception { + OrderBookEndpoint unit = new OrderBookEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/depth.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + KrakenResponse> response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + Map result = response.result().orElseThrow(); + + assertThat(result.get("XXBTZUSD").asks()).hasSize(2).first() + .isEqualTo(new OrderBook.Level(new BigDecimal("30384.10000"), new BigDecimal("2.059"), Instant.ofEpochSecond(1688671659))); + assertThat(result.get("XXBTZUSD").bids()).hasSize(2).first() + .isEqualTo(new OrderBook.Level(new BigDecimal("30297.00000"), new BigDecimal("1.115"), Instant.ofEpochSecond(1688671636))); + } + + @Test + void should_retain_display_pair_keys_when_asset_version_is_enabled() throws Exception { + OrderBookEndpoint unit = new OrderBookEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/depth.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8).replace("XXBTZUSD", "BTC/USD"); + } + + KrakenResponse> response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + Map result = response.result().orElseThrow(); + + assertThat(result).containsOnlyKeys("BTC/USD"); + } + + @Test + void should_return_empty_collections_when_no_entries_are_available() throws Exception { + OrderBookEndpoint unit = new OrderBookEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json = """ + {"error":[],"result":{"BTC/USD":{"asks":[],"bids":[]}}} + """; + + KrakenResponse> response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + Map result = response.result().orElseThrow(); + + assertThat(result.get("BTC/USD").asks()).isEmpty(); + assertThat(result.get("BTC/USD").bids()).isEmpty(); + } + @Test + void should_expose_endpoint_path_when_listing_api_endpoints() { + assertThat(Arrays.asList(KrakenAPI.Public.values())).extracting(KrakenAPI.Public::getPath).contains("Depth"); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/RecentSpreadsEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/RecentSpreadsEndpointTest.java new file mode 100644 index 0000000..bc2d808 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/RecentSpreadsEndpointTest.java @@ -0,0 +1,153 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.KrakenAPI; +import dev.andstuff.kraken.api.endpoint.KrakenResponse; +import dev.andstuff.kraken.api.endpoint.market.params.RecentSpreadsParams; +import dev.andstuff.kraken.api.endpoint.market.response.RecentSpreads; + +@ExtendWith(MockitoExtension.class) +class RecentSpreadsEndpointTest { + + @Test + void should_encode_all_documented_fields_when_all_options_are_set() { + RecentSpreadsEndpoint unit = new RecentSpreadsEndpoint(RecentSpreadsParams.builder().pair("BTC/USD").since(1688672106L).assetVersion(1).assetClass("tokenized_asset").build()); + + URL url = unit.buildURL(); + Map parameters = Arrays.stream(url.getQuery().split("&")) + .map(entry -> entry.split("=", 2)) + .collect(Collectors.toMap(entry -> URLDecoder.decode(entry[0], StandardCharsets.UTF_8), + entry -> URLDecoder.decode(entry[1], StandardCharsets.UTF_8))); + + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + assertThat(url).hasProtocol("https").hasHost("api.kraken.com").hasPath("/0/public/Spread"); + assertThat(parameters).containsExactlyInAnyOrderEntriesOf(Map.of("pair", "BTC/USD", "since", "1688672106", "assetVersion", "1", "asset_class", "tokenized_asset")); + } + + @Test + void should_omit_optional_parameters_when_only_the_pair_is_provided() { + RecentSpreadsEndpoint unit = new RecentSpreadsEndpoint("BTC/USD"); + + URL result = unit.buildURL(); + + assertThat(result).hasParameter("pair", "BTC/USD"); + assertThat(result.getQuery()).isEqualTo("pair=BTC%2FUSD"); + } + + @Test + void should_reject_missing_pair_when_building_parameters() { + RecentSpreadsParams.RecentSpreadsParamsBuilder unit = RecentSpreadsParams.builder(); + + assertThatThrownBy(unit::build).isInstanceOf(NullPointerException.class).hasMessageContaining("pair"); + } + + @Test + void should_retain_spread_positions_and_cursor_when_decoding_documented_response() throws Exception { + RecentSpreadsEndpoint unit = new RecentSpreadsEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/spread.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + RecentSpreads result = response.result().orElseThrow(); + + assertThat(result.spreads().get("XXBTZUSD")).hasSize(2).first() + .isEqualTo(new RecentSpreads.Spread(Instant.ofEpochSecond(1688671834), new BigDecimal("30292.10000"), new BigDecimal("30297.50000"))); + assertThat(result.last()).isEqualTo(1688672106L); + assertThat(RecentSpreadsParams.builder().pair("BTC/USD").since(result.last()).build().toMap()).containsEntry("since", "1688672106"); + } + + @Test + void should_retain_display_pair_keys_when_asset_version_is_enabled() throws Exception { + RecentSpreadsEndpoint unit = new RecentSpreadsEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/spread.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8).replace("XXBTZUSD", "BTC/USD"); + } + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + RecentSpreads result = response.result().orElseThrow(); + + assertThat(result.spreads()).containsOnlyKeys("BTC/USD"); + } + + @Test + void should_separate_cursor_from_pair_entries_when_cursor_precedes_multiple_pairs() throws Exception { + RecentSpreadsEndpoint unit = new RecentSpreadsEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json = """ + {"error":[],"result":{"last":123,"BTC/USD":[],"ETH/USD":[]}} + """; + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + RecentSpreads result = response.result().orElseThrow(); + + assertThat(result.spreads()).containsExactlyInAnyOrderEntriesOf(Map.of("BTC/USD", List.of(), "ETH/USD", List.of())); + assertThat(result.last()).isEqualTo(123L); + } + + @Test + void should_return_empty_collections_when_no_entries_are_available() throws Exception { + RecentSpreadsEndpoint unit = new RecentSpreadsEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json = """ + {"error":[],"result":{"BTC/USD":[],"last":123}} + """; + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + RecentSpreads result = response.result().orElseThrow(); + + assertThat(result.spreads().get("BTC/USD")).isEmpty(); + } + @Test + void should_expose_endpoint_path_when_listing_api_endpoints() { + assertThat(Arrays.asList(KrakenAPI.Public.values())).extracting(KrakenAPI.Public::getPath).contains("Spread"); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/RecentTradesEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/RecentTradesEndpointTest.java new file mode 100644 index 0000000..49b951b --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/RecentTradesEndpointTest.java @@ -0,0 +1,198 @@ +package dev.andstuff.kraken.api.endpoint.market; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import dev.andstuff.kraken.api.KrakenAPI; +import dev.andstuff.kraken.api.endpoint.KrakenResponse; +import dev.andstuff.kraken.api.endpoint.market.params.RecentTradesParams; +import dev.andstuff.kraken.api.endpoint.market.response.RecentTrades; + +@ExtendWith(MockitoExtension.class) +class RecentTradesEndpointTest { + + @Test + void should_encode_all_documented_fields_when_all_options_are_set() { + RecentTradesEndpoint unit = new RecentTradesEndpoint(RecentTradesParams.builder().pair("BTC/USD").since("1688671969993150842").count(2).assetVersion(1).assetClass("tokenized_asset").build()); + + URL url = unit.buildURL(); + Map parameters = Arrays.stream(url.getQuery().split("&")) + .map(entry -> entry.split("=", 2)) + .collect(Collectors.toMap(entry -> URLDecoder.decode(entry[0], StandardCharsets.UTF_8), + entry -> URLDecoder.decode(entry[1], StandardCharsets.UTF_8))); + + assertThat(unit.getHttpMethod()).isEqualTo("GET"); + assertThat(url).hasProtocol("https").hasHost("api.kraken.com").hasPath("/0/public/Trades"); + assertThat(parameters).containsExactlyInAnyOrderEntriesOf(Map.of("pair", "BTC/USD", "since", "1688671969993150842", "count", "2", "assetVersion", "1", "asset_class", "tokenized_asset")); + } + + @Test + void should_omit_optional_parameters_when_only_the_pair_is_provided() { + RecentTradesEndpoint unit = new RecentTradesEndpoint("BTC/USD"); + + URL result = unit.buildURL(); + + assertThat(result).hasParameter("pair", "BTC/USD"); + assertThat(result.getQuery()).isEqualTo("pair=BTC%2FUSD"); + } + + @Test + void should_reject_missing_pair_when_building_parameters() { + RecentTradesParams.RecentTradesParamsBuilder unit = RecentTradesParams.builder(); + + assertThatThrownBy(unit::build).isInstanceOf(NullPointerException.class).hasMessageContaining("pair"); + } + + @Test + void should_retain_trade_codes_decimal_time_and_opaque_cursor_when_decoding_documented_response() throws Exception { + RecentTradesEndpoint unit = new RecentTradesEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/trades.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8); + } + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + RecentTrades result = response.result().orElseThrow(); + + assertThat(result.trades().get("XXBTZUSD")).hasSize(2).first().isEqualTo( + new RecentTrades.Trade(new BigDecimal("30243.40000"), new BigDecimal("0.34507674"), Instant.ofEpochSecond(1688669597L, 827736900L), + RecentTrades.Side.BUY, RecentTrades.OrderType.MARKET, "", 61044952)); + assertThat(result.trades().get("XXBTZUSD").getLast()).extracting(RecentTrades.Trade::side, RecentTrades.Trade::orderType) + .containsExactly(RecentTrades.Side.SELL, RecentTrades.OrderType.LIMIT); + assertThat(result.last()).isEqualTo("1688671969993150842"); + assertThat(RecentTradesParams.builder().pair("BTC/USD").since(result.last()).build().toMap()).containsEntry("since", result.last()); + } + + @Test + void should_retain_display_pair_keys_when_asset_version_is_enabled() throws Exception { + RecentTradesEndpoint unit = new RecentTradesEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/trades.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8).replace("XXBTZUSD", "BTC/USD"); + } + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + RecentTrades result = response.result().orElseThrow(); + + assertThat(result.trades()).containsOnlyKeys("BTC/USD"); + } + + @Test + void should_separate_cursor_from_pair_entries_when_cursor_precedes_multiple_pairs() throws Exception { + RecentTradesEndpoint unit = new RecentTradesEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json = """ + {"error":[],"result":{"last":"001234567890123456789","BTC/USD":[],"ETH/USD":[]}} + """; + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + RecentTrades result = response.result().orElseThrow(); + + assertThat(result.trades()).containsExactlyInAnyOrderEntriesOf(Map.of("BTC/USD", List.of(), "ETH/USD", List.of())); + assertThat(result.last()).isEqualTo("001234567890123456789"); + } + + @Test + void should_return_empty_collections_when_no_entries_are_available() throws Exception { + RecentTradesEndpoint unit = new RecentTradesEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json = """ + {"error":[],"result":{"BTC/USD":[],"last":"001234567890123456789"}} + """; + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + RecentTrades result = response.result().orElseThrow(); + + assertThat(result.trades().get("BTC/USD")).isEmpty(); + } + + @Test + void should_preserve_fractional_precision_and_large_trade_ids_when_decoding_numeric_values() throws Exception { + RecentTradesEndpoint unit = new RecentTradesEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/trades.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8).replace("1688669597.8277369", "1688669597.123456789").replace("61044952", "61044952000000001"); + } + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + RecentTrades result = response.result().orElseThrow(); + + assertThat(result.trades().get("XXBTZUSD").getFirst()).extracting(RecentTrades.Trade::time, RecentTrades.Trade::tradeId) + .containsExactly(Instant.ofEpochSecond(1688669597L, 123456789L), 61044952000000001L); + } + + @Test + void should_fall_back_to_unknown_when_trade_codes_are_unrecognized() throws Exception { + RecentTradesEndpoint unit = new RecentTradesEndpoint("BTC/USD"); + ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModules(new JavaTimeModule(), new Jdk8Module()) + .build(); + String json; + try (InputStream fixture = getClass().getResourceAsStream("/market/trades.json")) { + json = new String(fixture.readAllBytes(), StandardCharsets.UTF_8).replace("\"b\"", "\"future-side\"").replace("\"m\"", "\"future-type\""); + } + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + RecentTrades result = response.result().orElseThrow(); + + assertThat(result.trades().get("XXBTZUSD").getFirst()).extracting(RecentTrades.Trade::side, RecentTrades.Trade::orderType) + .containsExactly(RecentTrades.Side.UNKNOWN, RecentTrades.OrderType.UNKNOWN); + } + @Test + void should_expose_endpoint_path_when_listing_api_endpoints() { + assertThat(Arrays.asList(KrakenAPI.Public.values())).extracting(KrakenAPI.Public::getPath).contains("Trades"); + } +} diff --git a/library/src/test/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequesterMarketTest.java b/library/src/test/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequesterMarketTest.java new file mode 100644 index 0000000..6a0cfd1 --- /dev/null +++ b/library/src/test/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequesterMarketTest.java @@ -0,0 +1,49 @@ +package dev.andstuff.kraken.api.rest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import javax.net.ssl.HttpsURLConnection; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import dev.andstuff.kraken.api.endpoint.market.RecentTradesEndpoint; +import dev.andstuff.kraken.api.endpoint.market.response.RecentTrades; + +@ExtendWith(MockitoExtension.class) +class DefaultKrakenRestRequesterMarketTest { + + @Mock private DefaultKrakenRestRequester.ConnectionFactory connectionFactory; + @Mock private HttpsURLConnection connection; + + @Test + void should_decode_precise_timestamps_and_future_enums_when_using_production_mapper() throws Exception { + DefaultKrakenRestRequester unit = new DefaultKrakenRestRequester(connectionFactory); + RecentTradesEndpoint endpoint = new RecentTradesEndpoint("BTC/USD"); + when(connectionFactory.open(any(URL.class))).thenReturn(connection); + when(connection.getHeaderField("Content-Type")).thenReturn("application/json"); + String json = """ + {"error":[],"result":{"BTC/USD":[["0.0000000000123456789","0.1",1688669597.123456789,"future-side","m","",61044952000000001]],"last":"opaque-cursor"}} + """; + when(connection.getInputStream()).thenReturn(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + + RecentTrades result = unit.execute(endpoint); + + assertThat(result.last()).isEqualTo("opaque-cursor"); + assertThat(result.trades().get("BTC/USD")).singleElement().satisfies(trade -> { + assertThat(trade.time()).isEqualTo(Instant.ofEpochSecond(1688669597L, 123456789L)); + assertThat(trade.price()).isEqualByComparingTo("0.0000000000123456789"); + assertThat(trade.side()).isEqualTo(RecentTrades.Side.UNKNOWN); + assertThat(trade.orderType()).isEqualTo(RecentTrades.OrderType.MARKET); + assertThat(trade.tradeId()).isEqualTo(61044952000000001L); + }); + } +} diff --git a/library/src/test/resources/market/README.md b/library/src/test/resources/market/README.md new file mode 100644 index 0000000..09fab25 --- /dev/null +++ b/library/src/test/resources/market/README.md @@ -0,0 +1,3 @@ +Fixtures are representative excerpts from the response examples in Kraken's [Spot REST OpenAPI specification](https://docs.kraken.com/openapi/spot-rest.yaml), retrieved 2026-09-07. Order books and time series are shortened. `depth.json` includes an extra property to check forward-compatible deserialization. + +Tests also generate empty responses, display-name keys, future enum values, and extra precision cases from these fixtures. No test calls the Kraken API. diff --git a/library/src/test/resources/market/depth.json b/library/src/test/resources/market/depth.json new file mode 100644 index 0000000..ca0d556 --- /dev/null +++ b/library/src/test/resources/market/depth.json @@ -0,0 +1,10 @@ +{ + "error": [], + "result": { + "XXBTZUSD": { + "asks": [["30384.10000", "2.059", 1688671659], ["30387.90000", "1.500", 1688671380]], + "bids": [["30297.00000", "1.115", 1688671636], ["30296.70000", "2.002", 1688671674]], + "future_field": true + } + } +} diff --git a/library/src/test/resources/market/grouped-book.json b/library/src/test/resources/market/grouped-book.json new file mode 100644 index 0000000..d3a5860 --- /dev/null +++ b/library/src/test/resources/market/grouped-book.json @@ -0,0 +1,9 @@ +{ + "error": [], + "result": { + "pair": "BTC/USD", + "grouping": 1000, + "bids": [{"price": "90400.00000", "qty": "19.83057746"}, {"price": "90300.00000", "qty": "45.35073006"}], + "asks": [{"price": "90500.00000", "qty": "38.96185061"}, {"price": "90600.00000", "qty": "55.96402032"}] + } +} diff --git a/library/src/test/resources/market/level3.json b/library/src/test/resources/market/level3.json new file mode 100644 index 0000000..0381bbc --- /dev/null +++ b/library/src/test/resources/market/level3.json @@ -0,0 +1,14 @@ +{ + "error": [], + "result": { + "pair": "YFI/EUR", + "bids": [ + {"price": "3062.00000", "qty": "0.29665800", "order_id": "O5KJU4-IEQTM-NDMS6W", "timestamp": 1765622008594292000}, + {"price": "3062.00000", "qty": "0.13917400", "order_id": "OERRY6-MXYER-6EQKNY", "timestamp": 1765622011396903000} + ], + "asks": [ + {"price": "3066.00000", "qty": "0.00278335", "order_id": "ORAWGV-N5L4J-LBA3WH", "timestamp": 1765622008499456000}, + {"price": "3067.00000", "qty": "0.13902210", "order_id": "OZWNZS-QE3G6-ZPKZUT", "timestamp": 1765622021013826600} + ] + } +} diff --git a/library/src/test/resources/market/maintenance-schedule.json b/library/src/test/resources/market/maintenance-schedule.json new file mode 100644 index 0000000..519bf9a --- /dev/null +++ b/library/src/test/resources/market/maintenance-schedule.json @@ -0,0 +1,18 @@ +{ + "error": [], + "result": { + "events": [{ + "event_id": 21, + "title": "Scheduled Maintenance - Website", + "expected_start_utc": "2026-05-11T09:00:00Z", + "expected_end_utc": "2026-05-11T10:00:00Z", + "time_to_start_s": 1740, + "phase": "approaching_30m", + "affected_services": ["spot_trading"], + "order_submission": "allowed", + "recommended_action": "reduce_activity", + "cancel_before_utc": "2026-05-11T08:55:00Z", + "source_url": "https://status.kraken.com/incidents/b7k2r9wqmn41" + }] + } +} diff --git a/library/src/test/resources/market/ohlc.json b/library/src/test/resources/market/ohlc.json new file mode 100644 index 0000000..4d87b71 --- /dev/null +++ b/library/src/test/resources/market/ohlc.json @@ -0,0 +1,10 @@ +{ + "error": [], + "result": { + "XXBTZUSD": [ + [1688671200, "30306.1", "30306.2", "30305.7", "30305.7", "30306.1", "3.39243896", 23], + [1688671260, "30304.5", "30304.5", "30300.0", "30300.0", "30300.0", "4.42996871", 18] + ], + "last": 1688672160 + } +} diff --git a/library/src/test/resources/market/spread.json b/library/src/test/resources/market/spread.json new file mode 100644 index 0000000..2910c91 --- /dev/null +++ b/library/src/test/resources/market/spread.json @@ -0,0 +1,10 @@ +{ + "error": [], + "result": { + "XXBTZUSD": [ + [1688671834, "30292.10000", "30297.50000"], + [1688671834, "30292.10000", "30296.70000"] + ], + "last": 1688672106 + } +} diff --git a/library/src/test/resources/market/trades.json b/library/src/test/resources/market/trades.json new file mode 100644 index 0000000..99db8db --- /dev/null +++ b/library/src/test/resources/market/trades.json @@ -0,0 +1,10 @@ +{ + "error": [], + "result": { + "XXBTZUSD": [ + ["30243.40000", "0.34507674", 1688669597.8277369, "b", "m", "", 61044952], + ["30243.30000", "0.00376960", 1688669598.2804112, "s", "l", "", 61044953] + ], + "last": "1688671969993150842" + } +} diff --git a/pom.xml b/pom.xml index 5976be8..4b6a361 100644 --- a/pom.xml +++ b/pom.xml @@ -54,6 +54,9 @@ 2.0.18 2.23.0 5.12.0 + 5.14.0 + 3.27.7 + 5.23.0 3.15.0 @@ -62,6 +65,7 @@ 3.12.0 3.3.1 3.4.0 + 3.5.4 0.11.0 @@ -94,6 +98,23 @@ opencsv ${opencsv.version} + + org.junit + junit-bom + ${junit.version} + import + pom + + + org.assertj + assertj-core + ${assertj.version} + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + @@ -127,6 +148,11 @@ + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + org.apache.maven.plugins maven-compiler-plugin