From e6594d3d6b526302d0dbb7493b706550f03e18aa Mon Sep 17 00:00:00 2001 From: nyg Date: Mon, 21 Sep 2026 21:11:48 +0200 Subject: [PATCH] feat: type system status advisories and close remaining Spot REST spec gaps --- .../dev/andstuff/kraken/api/KrakenAPI.java | 16 +++- .../account/params/LedgerEntriesParams.java | 3 + .../account/params/LedgerInfoParams.java | 3 + .../market/response/MaintenanceSchedule.java | 4 +- .../market/response/SystemStatus.java | 81 ++++++++++++++++++- .../transparency/params/PostTradeParams.java | 7 +- .../api/rest/DefaultKrakenRestRequester.java | 4 +- .../kraken/api/KrakenAPITransparencyTest.java | 12 +++ .../account/LedgerEntriesEndpointTest.java | 8 +- .../account/LedgerInfoEndpointTest.java | 6 +- .../api/endpoint/market/MarketDataTest.java | 2 +- .../market/SystemStatusEndpointTest.java | 71 +++++++++++++++- .../transparency/PostTradeEndpointTest.java | 19 ++++- ...DefaultKrakenRestRequesterAccountTest.java | 9 ++- library/src/test/resources/market/README.md | 2 +- 15 files changed, 218 insertions(+), 29 deletions(-) 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 3bf884f..d41b50f 100644 --- a/library/src/main/java/dev/andstuff/kraken/api/KrakenAPI.java +++ b/library/src/main/java/dev/andstuff/kraken/api/KrakenAPI.java @@ -325,7 +325,7 @@ public ServerTime serverTime() { } /** - * Queries the {@code SystemStatus} endpoint, returning the current status of the Kraken trading system. + * Queries the {@code SystemStatus} endpoint, returning the current status of the Kraken trading system, along with scheduled maintenance and unresolved incidents. * * @return the system status * @throws KrakenException if Kraken returns an error @@ -532,6 +532,16 @@ public PreTrade preTrade(String symbol) { return query(new PreTradeEndpoint(PreTradeParams.of(symbol))); } + /** + * Queries the {@code PostTrade} endpoint, returning the last 1000 trades executed on the spot exchange, all pairs included. + * + * @return the executed trades + * @throws KrakenException if Kraken returns an error + */ + public PostTrade postTrade() { + return postTrade(PostTradeParams.builder().build()); + } + /** * Queries the {@code PostTrade} endpoint, returning the last 1000 trades executed on a currency pair. * @@ -544,9 +554,9 @@ public PostTrade postTrade(String symbol) { } /** - * Queries the {@code PostTrade} endpoint, returning the trades executed on a currency pair over the given period. Trades are returned in ascending time order and at most 1000 at a time: {@link PostTrade#lastTimestamp()} gives the timestamp to use as the next {@code fromTimestamp}. + * Queries the {@code PostTrade} endpoint, returning the trades matching the given symbol, period and count. Trades are returned in ascending time order and at most 1000 at a time: {@link PostTrade#lastTimestamp()} gives the timestamp to use as the next {@code fromTimestamp}. * - * @param params the currency pair and the period and count restricting the trades returned + * @param params the currency pair, period and count restricting the trades returned * @return the executed trades * @throws KrakenException if Kraken returns an error */ diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/account/params/LedgerEntriesParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/account/params/LedgerEntriesParams.java index ffd469c..3cb14d5 100644 --- a/library/src/main/java/dev/andstuff/kraken/api/endpoint/account/params/LedgerEntriesParams.java +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/account/params/LedgerEntriesParams.java @@ -5,6 +5,7 @@ import java.util.Map; import dev.andstuff.kraken.api.endpoint.priv.PostParams; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; import lombok.Builder; import lombok.Getter; @@ -18,12 +19,14 @@ public class LedgerEntriesParams extends PostParams { @Builder.Default private final List entryIds = List.of(); private final boolean includeTrades; + private final RebaseMultiplier rebaseMultiplier; @Override public Map params() { Map params = new HashMap<>(); putIfNonNull(params, "id", entryIds, v -> String.join(",", v)); putIfNonNull(params, "trades", includeTrades); + putIfNonNull(params, "rebase_multiplier", rebaseMultiplier, RebaseMultiplier::getValue); return params; } } diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/account/params/LedgerInfoParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/account/params/LedgerInfoParams.java index 4db0d18..f284e61 100644 --- a/library/src/main/java/dev/andstuff/kraken/api/endpoint/account/params/LedgerInfoParams.java +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/account/params/LedgerInfoParams.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; import dev.andstuff.kraken.api.endpoint.priv.PostParams; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; import lombok.Builder; import lombok.Getter; import lombok.With; @@ -27,6 +28,7 @@ public class LedgerInfoParams extends PostParams { private final String fromLedgerId; private final String toLedgerId; private final boolean withoutCount; + private final RebaseMultiplier rebaseMultiplier; @With @Builder.Default @@ -55,6 +57,7 @@ protected Map params() { putIfNonNull(params, "without_count", withoutCount); putIfNonNull(params, "ofs", resultOffset); + putIfNonNull(params, "rebase_multiplier", rebaseMultiplier, RebaseMultiplier::getValue); return params; } 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 index 1c89fee..c8085e4 100644 --- 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 @@ -14,7 +14,7 @@ public record MaintenanceSchedule(List events) { /** - * A scheduled maintenance event returned by the {@code MaintenanceSchedule} endpoint. + * A scheduled maintenance event returned by the {@code MaintenanceSchedule} and {@code SystemStatus} endpoints. * * @param eventId stable event identifier * @param title event title @@ -49,7 +49,7 @@ public enum Phase { } /** - * A Kraken service affected by a {@code MaintenanceSchedule} event. + * A Kraken service affected by a scheduled maintenance event or an incident. */ public enum Service { SPOT_WS, SPOT_REST, SPOT_FIX, SPOT_TRADING, diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/SystemStatus.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/SystemStatus.java index be0242c..f48f984 100644 --- a/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/SystemStatus.java +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/market/response/SystemStatus.java @@ -1,17 +1,37 @@ package dev.andstuff.kraken.api.endpoint.market.response; import java.time.Instant; +import java.util.List; +import java.util.Objects; import com.fasterxml.jackson.annotation.JsonEnumDefaultValue; +import com.fasterxml.jackson.annotation.JsonProperty; /** - * The response of the {@code SystemStatus} endpoint. + * The response of the {@code SystemStatus} endpoint. The advisories explain why a trading mode is in effect or coming, but the trading mode itself is only given by {@code status}. * * @param status the current status of the Kraken trading system * @param timestamp the time the status was last updated + * @param upcomingMaintenance the maintenance events scheduled within the next 72 hours, by ascending start time, empty if there are none + * @param emergency the unresolved incidents, empty if there are none */ public record SystemStatus(Description status, - Instant timestamp) { + Instant timestamp, + @JsonProperty("upcoming_maintenance") List upcomingMaintenance, + List emergency) { + + /** + * Creates the response, replacing advisory lists Kraken omits with empty ones. + * + * @param status the current status of the Kraken trading system + * @param timestamp the time the status was last updated + * @param upcomingMaintenance the scheduled maintenance events, possibly {@code null} + * @param emergency the unresolved incidents, possibly {@code null} + */ + public SystemStatus { + upcomingMaintenance = Objects.requireNonNullElse(upcomingMaintenance, List.of()); + emergency = Objects.requireNonNullElse(emergency, List.of()); + } /** * The trading mode of the Kraken trading system. @@ -25,4 +45,61 @@ public enum Description { @JsonEnumDefaultValue UNKNOWN } + + /** + * An unplanned incident, relayed from Kraken's status page until it is resolved. + * + * @param eventId stable incident identifier + * @param title incident title + * @param incidentStatus lifecycle state of the incident + * @param impact severity of the incident + * @param affectedServices affected Kraken services + * @param startedAt time the incident was opened + * @param nextSteps forecast operational actions, by ascending time, empty if none was published + * @param sourceUrl link to the incident on Kraken's status page + */ + public record Emergency(@JsonProperty("event_id") long eventId, + String title, + @JsonProperty("incident_status") IncidentStatus incidentStatus, + Impact impact, + @JsonProperty("affected_services") List affectedServices, + @JsonProperty("started_at_utc") Instant startedAt, + @JsonProperty("next_steps") List nextSteps, + @JsonProperty("source_url") String sourceUrl) {} + + /** + * An operational action Kraken forecasts during an incident. + * + * @param appliesTo Kraken services the action applies to + * @param type nature of the action + * @param expectedAt forecast time of the action + */ + public record NextStep(@JsonProperty("applies_to") List appliesTo, + Type type, + @JsonProperty("expected_at_utc") Instant expectedAt) { + + /** + * The nature of a forecast action. + */ + public enum Type { + EXPECTED_RESTART, EXPECTED_CANCEL_ONLY, EXPECTED_POST_ONLY, EXPECTED_ONLINE, + @JsonEnumDefaultValue UNKNOWN + } + } + + /** + * The lifecycle state of an incident. + */ + public enum IncidentStatus { + INVESTIGATING, IDENTIFIED, MONITORING, + @JsonEnumDefaultValue UNKNOWN + } + + /** + * The severity of an incident, as published on Kraken's status page. + */ + public enum Impact { + NONE, MINOR, MAJOR, CRITICAL, + @JsonEnumDefaultValue UNKNOWN + } } diff --git a/library/src/main/java/dev/andstuff/kraken/api/endpoint/transparency/params/PostTradeParams.java b/library/src/main/java/dev/andstuff/kraken/api/endpoint/transparency/params/PostTradeParams.java index 4d62f70..2aa7537 100644 --- a/library/src/main/java/dev/andstuff/kraken/api/endpoint/transparency/params/PostTradeParams.java +++ b/library/src/main/java/dev/andstuff/kraken/api/endpoint/transparency/params/PostTradeParams.java @@ -9,18 +9,15 @@ import dev.andstuff.kraken.api.endpoint.pub.QueryParams; import lombok.Builder; import lombok.Getter; -import lombok.NonNull; /** - * The parameters of the {@code PostTrade} endpoint. The symbol is required, in the {@code BASE/QUOTE} display format, and the trades can be further restricted to a period and to a maximum count. + * The parameters of the {@code PostTrade} endpoint. All of them are optional: the trades can be restricted to a symbol, in the {@code BASE/QUOTE} display format, to a period and to a maximum count. Without any of them, Kraken returns the last 1000 trades of all pairs. */ @Getter @Builder(toBuilder = true) public class PostTradeParams implements QueryParams { - @NonNull private final String symbol; - private final Instant fromTimestamp; private final Instant toTimestamp; private final Integer count; @@ -28,7 +25,7 @@ public class PostTradeParams implements QueryParams { @Override public Map toMap() { Map params = new HashMap<>(); - params.put("symbol", symbol); + putIfNonNull(params, "symbol", symbol, v -> v); putIfNonNull(params, "from_ts", fromTimestamp, Instant::toString); putIfNonNull(params, "to_ts", toTimestamp, Instant::toString); putIfNonNull(params, "count", count, String::valueOf); 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 b578b78..b3b3260 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 @@ -28,7 +28,7 @@ /** * {@link KrakenRestRequester} implementation using {@link HttpsURLConnection}. * - *

JSON responses are deserialized with a Jackson mapper configured to be lenient with unknown properties and enum values, so that new fields returned by Kraken don't break deserialization. Responses of type {@code application/zip}, e.g. report exports, are handed to {@link Endpoint#processZipResponse(java.util.zip.ZipInputStream)}. Funding (Beta) responses are deserialized from the whole body, and their HTTP error statuses are raised as a {@link KrakenException}. + *

JSON responses are deserialized with a Jackson mapper configured to be lenient with unknown properties and enum values, so that new fields returned by Kraken don't break deserialization. Responses of type {@code application/zip} or {@code application/octet-stream}, e.g. report exports, are handed to {@link Endpoint#processZipResponse(java.util.zip.ZipInputStream)}. Funding (Beta) responses are deserialized from the whole body, and their HTTP error statuses are raised as a {@link KrakenException}. */ @Slf4j public class DefaultKrakenRestRequester implements KrakenRestRequester { @@ -153,7 +153,7 @@ private static T parseResponse(HttpsURLConnection connection, Endpoint en KrakenResponse response = OBJECT_MAPPER.readValue(connection.getInputStream(), krakenResponseType); return endpoint.unwrapResponse(response); } - else if ("application/zip".equals(contentType)) { + else if ("application/zip".equals(contentType) || "application/octet-stream".equals(contentType)) { try (ZipInputStream zipStream = new ZipInputStream(connection.getInputStream())) { return endpoint.processZipResponse(zipStream); } diff --git a/library/src/test/java/dev/andstuff/kraken/api/KrakenAPITransparencyTest.java b/library/src/test/java/dev/andstuff/kraken/api/KrakenAPITransparencyTest.java index 2cf5b6e..fd342c2 100644 --- a/library/src/test/java/dev/andstuff/kraken/api/KrakenAPITransparencyTest.java +++ b/library/src/test/java/dev/andstuff/kraken/api/KrakenAPITransparencyTest.java @@ -50,6 +50,18 @@ void should_route_postTrade_symbol_without_credentials_when_called() { verify(requester).execute(argThat((PostTradeEndpoint endpoint) -> endpoint.buildURL().getQuery().equals("symbol=BTC%2FUSD"))); } + @Test + void should_route_postTrade_of_all_pairs_without_credentials_when_called() { + PostTrade postTradeResponse = new PostTrade(null, 0, List.of()); + KrakenAPI unit = new KrakenAPI(null, requester); + when(requester.execute(any(PostTradeEndpoint.class))).thenReturn(postTradeResponse); + + PostTrade result = unit.postTrade(); + + assertThat(result).isSameAs(postTradeResponse); + verify(requester).execute(argThat((PostTradeEndpoint endpoint) -> endpoint.buildURL().getQuery() == null)); + } + @Test void should_route_postTrade_options_without_credentials_when_called() { PostTrade postTradeResponse = new PostTrade(Instant.parse("2024-05-30T12:34:56.123456789Z"), 0, List.of()); diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/account/LedgerEntriesEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/account/LedgerEntriesEndpointTest.java index d6deb2f..0f0d009 100644 --- a/library/src/test/java/dev/andstuff/kraken/api/endpoint/account/LedgerEntriesEndpointTest.java +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/account/LedgerEntriesEndpointTest.java @@ -25,6 +25,7 @@ import dev.andstuff.kraken.api.endpoint.KrakenResponse; import dev.andstuff.kraken.api.endpoint.account.params.LedgerEntriesParams; import dev.andstuff.kraken.api.endpoint.account.response.LedgerEntry; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; @ExtendWith(MockitoExtension.class) class LedgerEntriesEndpointTest { @@ -32,7 +33,7 @@ class LedgerEntriesEndpointTest { @Test void should_encode_all_options_when_supplied() { LedgerEntriesEndpoint unit = new LedgerEntriesEndpoint(LedgerEntriesParams.builder() - .entryIds(List.of("L4UESK-KG3EQ-UFO4T5", "LMKZCZ-Z3GVL-CXKK4H")).includeTrades(true).build()); + .entryIds(List.of("L4UESK-KG3EQ-UFO4T5", "LMKZCZ-Z3GVL-CXKK4H")).includeTrades(true).rebaseMultiplier(RebaseMultiplier.REBASED).build()); Map result = Arrays.stream(unit.encodedParamsWith("123456789").split("&")) .map(value -> value.split("=", 2)) @@ -41,7 +42,8 @@ void should_encode_all_options_when_supplied() { assertThat(result).containsExactlyInAnyOrderEntriesOf(Map.ofEntries( Map.entry("nonce", "123456789"), Map.entry("id", "L4UESK-KG3EQ-UFO4T5,LMKZCZ-Z3GVL-CXKK4H"), - Map.entry("trades", "true"))); + Map.entry("trades", "true"), + Map.entry("rebase_multiplier", "rebased"))); assertThat(unit.buildURL().getPath()).isEqualTo("/0/private/QueryLedgers"); assertThat(unit.getHttpMethod()).isEqualTo("POST"); assertThat(unit.getContentType()).isEqualTo("application/x-www-form-urlencoded"); @@ -53,7 +55,7 @@ void should_exclude_trades_when_not_requested() { String result = unit.encodedParamsWith("123"); - assertThat(result).contains("trades=false").contains("id=L4UESK-KG3EQ-UFO4T5").endsWith("nonce=123"); + assertThat(result).contains("trades=false").contains("id=L4UESK-KG3EQ-UFO4T5").doesNotContain("rebase_multiplier").endsWith("nonce=123"); } @Test diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/account/LedgerInfoEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/account/LedgerInfoEndpointTest.java index 3912ce6..c336be4 100644 --- a/library/src/test/java/dev/andstuff/kraken/api/endpoint/account/LedgerInfoEndpointTest.java +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/account/LedgerInfoEndpointTest.java @@ -28,6 +28,7 @@ import dev.andstuff.kraken.api.endpoint.account.params.LedgerInfoParams; import dev.andstuff.kraken.api.endpoint.account.response.LedgerEntry; import dev.andstuff.kraken.api.endpoint.account.response.LedgerInfo; +import dev.andstuff.kraken.api.endpoint.priv.RebaseMultiplier; @ExtendWith(MockitoExtension.class) class LedgerInfoEndpointTest { @@ -36,7 +37,7 @@ class LedgerInfoEndpointTest { void should_encode_dates_over_ledger_ids_when_both_bounds_are_supplied() { LedgerInfoEndpoint unit = new LedgerInfoEndpoint(LedgerInfoParams.builder().assets(List.of("XXBT", "ZUSD")).assetClass("currency") .assetType(LedgerInfoParams.Type.NFT_REBATE).fromDate(Instant.ofEpochSecond(1688444262L)).toDate(Instant.ofEpochSecond(1688464484L)) - .fromLedgerId("LMKZCZ-Z3GVL-CXKK4H").toLedgerId("L4UESK-KG3EQ-UFO4T5").withoutCount(true).resultOffset(50).build()); + .fromLedgerId("LMKZCZ-Z3GVL-CXKK4H").toLedgerId("L4UESK-KG3EQ-UFO4T5").withoutCount(true).resultOffset(50).rebaseMultiplier(RebaseMultiplier.BASE).build()); Map result = Arrays.stream(unit.encodedParamsWith("123456789").split("&")) .map(value -> value.split("=", 2)) @@ -50,7 +51,8 @@ void should_encode_dates_over_ledger_ids_when_both_bounds_are_supplied() { Map.entry("start", "1688444262"), Map.entry("end", "1688464484"), Map.entry("without_count", "true"), - Map.entry("ofs", "50"))); + Map.entry("ofs", "50"), + Map.entry("rebase_multiplier", "base"))); assertThat(unit.buildURL().getPath()).isEqualTo("/0/private/Ledgers"); assertThat(unit.getHttpMethod()).isEqualTo("POST"); assertThat(unit.getContentType()).isEqualTo("application/x-www-form-urlencoded"); 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 index 4a45dc3..74ec744 100644 --- 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 @@ -283,7 +283,7 @@ void should_route_server_time_through_configured_requester_when_querying_time() @Test void should_route_system_status_through_configured_requester_when_querying_status() { KrakenAPI unit = new KrakenAPI(null, requester); - SystemStatus expected = new SystemStatus(SystemStatus.Description.ONLINE, Instant.parse("2023-07-06T18:52:00Z")); + SystemStatus expected = new SystemStatus(SystemStatus.Description.ONLINE, Instant.parse("2023-07-06T18:52:00Z"), List.of(), List.of()); when(requester.execute(any(SystemStatusEndpoint.class))).thenReturn(expected); SystemStatus result = unit.systemStatus(); diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/SystemStatusEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/SystemStatusEndpointTest.java index f8589a1..4c2a152 100644 --- a/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/SystemStatusEndpointTest.java +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/market/SystemStatusEndpointTest.java @@ -6,6 +6,7 @@ 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; @@ -21,6 +22,7 @@ import dev.andstuff.kraken.api.KrakenAPI; import dev.andstuff.kraken.api.endpoint.KrakenResponse; +import dev.andstuff.kraken.api.endpoint.market.response.MaintenanceSchedule; import dev.andstuff.kraken.api.endpoint.market.response.SystemStatus; @ExtendWith(MockitoExtension.class) @@ -39,7 +41,7 @@ void should_use_public_get_without_parameters_when_requesting_system_status() { } @Test - void should_read_status_and_timestamp_when_decoding_documented_emergency_response() throws Exception { + void should_read_status_and_incident_when_decoding_documented_emergency_response() throws Exception { ObjectMapper mapper = JsonMapper.builder() .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) .enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE) @@ -54,7 +56,13 @@ void should_read_status_and_timestamp_when_decoding_documented_emergency_respons KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); SystemStatus result = unit.unwrapResponse(response); - assertThat(result).isEqualTo(new SystemStatus(SystemStatus.Description.CANCEL_ONLY, Instant.parse("2026-05-08T14:28:00Z"))); + assertThat(result).isEqualTo(new SystemStatus(SystemStatus.Description.CANCEL_ONLY, Instant.parse("2026-05-08T14:28:00Z"), List.of(), + List.of(new SystemStatus.Emergency(4821, "Elevated API error rates", SystemStatus.IncidentStatus.IDENTIFIED, SystemStatus.Impact.CRITICAL, + List.of(MaintenanceSchedule.Service.SPOT_WS, MaintenanceSchedule.Service.SPOT_REST, MaintenanceSchedule.Service.SPOT_FIX), + Instant.parse("2026-05-08T14:23:11Z"), + List.of(new SystemStatus.NextStep(List.of(MaintenanceSchedule.Service.SPOT_TRADING), SystemStatus.NextStep.Type.EXPECTED_RESTART, + Instant.parse("2026-05-08T14:30:00Z"))), + "https://stspg.io/c378t4f7rh0n")))); } @Test @@ -73,6 +81,34 @@ void should_read_online_status_when_system_operates_normally() throws Exception SystemStatus result = unit.unwrapResponse(response); assertThat(result.status()).isEqualTo(SystemStatus.Description.ONLINE); + assertThat(result.upcomingMaintenance()).isEmpty(); + assertThat(result.emergency()).isEmpty(); + } + + @Test + void should_read_scheduled_event_when_maintenance_is_approaching() 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":{"status":"online","timestamp":"2026-05-11T08:30:00Z","upcoming_maintenance":[{"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"}],"emergency":[]}} + """; + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + SystemStatus result = unit.unwrapResponse(response); + + assertThat(result.upcomingMaintenance()).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")); + assertThat(result.emergency()).isEmpty(); } @Test @@ -91,5 +127,36 @@ void should_fall_back_to_unknown_status_when_kraken_adds_a_trading_mode() throws SystemStatus result = unit.unwrapResponse(response); assertThat(result.status()).isEqualTo(SystemStatus.Description.UNKNOWN); + assertThat(result.upcomingMaintenance()).isEmpty(); + assertThat(result.emergency()).isEmpty(); + } + + @Test + void should_fall_back_to_unknown_values_when_kraken_adds_incident_states() 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":{"status":"post_only","timestamp":"2026-05-08T14:28:00Z","emergency":[{"event_id":4822,"title":"Degraded order entry", + "incident_status":"future-status","impact":"future-impact","affected_services":["future-service"],"started_at_utc":"2026-05-08T14:23:11Z", + "next_steps":[{"applies_to":["all"],"type":"future-step","expected_at_utc":"2026-05-08T15:00:00Z"}]}]}} + """; + + KrakenResponse response = mapper.readValue(json, unit.wrappedResponseType(mapper.getTypeFactory())); + SystemStatus result = unit.unwrapResponse(response); + + assertThat(result.status()).isEqualTo(SystemStatus.Description.POST_ONLY); + assertThat(result.upcomingMaintenance()).isEmpty(); + assertThat(result.emergency()).singleElement().satisfies(emergency -> { + assertThat(emergency.incidentStatus()).isEqualTo(SystemStatus.IncidentStatus.UNKNOWN); + assertThat(emergency.impact()).isEqualTo(SystemStatus.Impact.UNKNOWN); + assertThat(emergency.affectedServices()).containsExactly(MaintenanceSchedule.Service.UNKNOWN); + assertThat(emergency.nextSteps()).containsExactly(new SystemStatus.NextStep(List.of(MaintenanceSchedule.Service.ALL), + SystemStatus.NextStep.Type.UNKNOWN, Instant.parse("2026-05-08T15:00:00Z"))); + assertThat(emergency.sourceUrl()).isNull(); + }); } } diff --git a/library/src/test/java/dev/andstuff/kraken/api/endpoint/transparency/PostTradeEndpointTest.java b/library/src/test/java/dev/andstuff/kraken/api/endpoint/transparency/PostTradeEndpointTest.java index ca59f71..135188c 100644 --- a/library/src/test/java/dev/andstuff/kraken/api/endpoint/transparency/PostTradeEndpointTest.java +++ b/library/src/test/java/dev/andstuff/kraken/api/endpoint/transparency/PostTradeEndpointTest.java @@ -1,7 +1,6 @@ package dev.andstuff.kraken.api.endpoint.transparency; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.io.InputStream; import java.math.BigDecimal; @@ -60,8 +59,22 @@ void should_omit_optional_parameters_when_only_the_symbol_is_provided() { } @Test - void should_reject_missing_symbol_when_building_parameters() { - assertThatThrownBy(() -> PostTradeParams.builder().count(10).build()).isInstanceOf(NullPointerException.class).hasMessageContaining("symbol"); + void should_omit_symbol_when_requesting_trades_of_all_pairs() { + PostTradeEndpoint unit = new PostTradeEndpoint(PostTradeParams.builder().count(10).build()); + + URL result = unit.buildURL(); + + assertThat(result).hasPath("/0/public/PostTrade").hasNoParameter("symbol"); + assertThat(result.getQuery()).isEqualTo("count=10"); + } + + @Test + void should_send_no_parameter_when_no_option_is_set() { + PostTradeEndpoint unit = new PostTradeEndpoint(PostTradeParams.builder().build()); + + URL result = unit.buildURL(); + + assertThat(result).hasPath("/0/public/PostTrade").hasNoParameters(); } @Test diff --git a/library/src/test/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequesterAccountTest.java b/library/src/test/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequesterAccountTest.java index 657ef2f..d79bb26 100644 --- a/library/src/test/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequesterAccountTest.java +++ b/library/src/test/java/dev/andstuff/kraken/api/rest/DefaultKrakenRestRequesterAccountTest.java @@ -21,6 +21,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -123,8 +125,9 @@ void should_sign_body_and_path_when_balance_selects_a_wallet() throws Exception verify(connection).addRequestProperty("API-Sign", "WRKuiZ89EQ2MHYfSRx7pHztnIMr+ivo8S8E5olq+GcpBVw97M1jKx1ElzpGgC36H/tn8Po28EwNryLvK+rl4ig=="); } - @Test - void should_parse_csv_ledger_entries_when_export_is_returned_as_zip() throws Exception { + @ParameterizedTest + @ValueSource(strings = {"application/zip", "application/octet-stream"}) + void should_parse_csv_ledger_entries_when_export_is_returned_as_archive(String contentType) throws Exception { DefaultKrakenRestRequester unit = new DefaultKrakenRestRequester(connectionFactory); ReportDataEndpoint endpoint = new ReportDataEndpoint(ReportDataParams.of("TCJA")); ByteArrayOutputStream archive = new ByteArrayOutputStream(); @@ -140,7 +143,7 @@ void should_parse_csv_ledger_entries_when_export_is_returned_as_zip() throws Exc when(nonceGenerator.generate()).thenReturn("123"); when(connectionFactory.open(any(URL.class))).thenReturn(connection); when(connection.getOutputStream()).thenReturn(output); - when(connection.getHeaderField("Content-Type")).thenReturn("application/zip"); + when(connection.getHeaderField("Content-Type")).thenReturn(contentType); when(connection.getInputStream()).thenReturn(new ByteArrayInputStream(archive.toByteArray())); List result = unit.execute(endpoint, credentials, nonceGenerator); diff --git a/library/src/test/resources/market/README.md b/library/src/test/resources/market/README.md index 07d2bda..72ea1cc 100644 --- a/library/src/test/resources/market/README.md +++ b/library/src/test/resources/market/README.md @@ -1,3 +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. `time.json`, `system-status.json`, `assets.json`, `asset-pairs.json` and `ticker.json` are the complete examples, retrieved on 2026-09-21; `system-status.json` is the `emergencyInProgress` example, whose maintenance and emergency details are ignored by the typed response. +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. `time.json`, `system-status.json`, `assets.json`, `asset-pairs.json` and `ticker.json` are the complete examples, retrieved on 2026-09-21; `system-status.json` is the `emergencyInProgress` example, and the `scheduledMaintenanceApproaching` example is inlined in the SystemStatus tests. Tests also generate empty responses, display-name keys, future enum values, fee tiers, restricted asset statuses and extra precision cases from these fixtures. No test calls the Kraken API.