diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9a06e..682c611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Fixed 🐛 + +- A backtest started with a numeric or boolean per-run `params` value is readable again. The generated + client decided which member of the `number | string | boolean` value a JSON scalar was by requiring + exactly one to accept it, but its mapper coerced scalars between JSON types, so every number and boolean + matched two or three members and the result failed with "Failed deserialization for + ScalarStrategyParamValue: N classes match result, expected 1". The platform had run the job; only its + result could not be read. Scalar coercion is now off in the client both `QTSurfer` and `authenticate(...)` + build. + ## [0.34.0] — 2026-10-23 ### Added ✨ diff --git a/src/main/java/com/qtsurfer/api/sdk/QTSurfer.java b/src/main/java/com/qtsurfer/api/sdk/QTSurfer.java index 6598b73..d2507de 100644 --- a/src/main/java/com/qtsurfer/api/sdk/QTSurfer.java +++ b/src/main/java/com/qtsurfer/api/sdk/QTSurfer.java @@ -53,6 +53,7 @@ import com.qtsurfer.api.sdk.auth.AuthenticatedClient; import com.qtsurfer.api.sdk.errors.QTSDownloadError; import com.qtsurfer.api.sdk.errors.QTSError; +import com.qtsurfer.api.sdk.internal.ApiClients; import com.qtsurfer.api.sdk.internal.DatasetUploads; import com.qtsurfer.api.sdk.internal.HttpStrategyCompileClient; import com.qtsurfer.api.sdk.internal.LivePageLinks; @@ -1230,7 +1231,7 @@ public static final class Builder { public QTSurfer build() { QTSurferOptions opts = delegate.build(); - ApiClient apiClient = new ApiClient(); + ApiClient apiClient = ApiClients.create(); apiClient.updateBaseUri(opts.baseUrl().toString()); if (opts.token() != null) { String bearer = "Bearer " + opts.token(); diff --git a/src/main/java/com/qtsurfer/api/sdk/auth/AuthenticatedClient.java b/src/main/java/com/qtsurfer/api/sdk/auth/AuthenticatedClient.java index 5143b75..188d248 100644 --- a/src/main/java/com/qtsurfer/api/sdk/auth/AuthenticatedClient.java +++ b/src/main/java/com/qtsurfer/api/sdk/auth/AuthenticatedClient.java @@ -71,6 +71,7 @@ import com.qtsurfer.api.sdk.errors.QTSDownloadError; import com.qtsurfer.api.sdk.errors.QTSError; import com.qtsurfer.api.sdk.internal.ApiCalls; +import com.qtsurfer.api.sdk.internal.ApiClients; import com.qtsurfer.api.sdk.internal.DatasetUploads; import com.qtsurfer.api.sdk.internal.LivePageLinks; import com.qtsurfer.api.sdk.internal.HttpStrategyCompileClient; @@ -1414,7 +1415,7 @@ private void mirror() { } private static ApiClient buildApiClient(AuthOptions opts, Consumer headers) { - ApiClient c = new ApiClient(); + ApiClient c = ApiClients.create(); c.updateBaseUri(opts.baseUrl().toString()); c.setRequestInterceptor(headers); return c; diff --git a/src/main/java/com/qtsurfer/api/sdk/internal/ApiClients.java b/src/main/java/com/qtsurfer/api/sdk/internal/ApiClients.java new file mode 100644 index 0000000..ca3564c --- /dev/null +++ b/src/main/java/com/qtsurfer/api/sdk/internal/ApiClients.java @@ -0,0 +1,35 @@ +package com.qtsurfer.api.sdk.internal; + +import com.fasterxml.jackson.databind.MapperFeature; +import com.qtsurfer.api.client.invoker.ApiClient; + +/** + * Builds the generated {@link ApiClient} the way every workflow of the SDK needs it. + * + *

The generated client reads a {@code oneOf} by trying each of its types and requiring exactly + * one to accept the value. With Jackson's default scalar coercion a JSON number is also accepted + * as a string and as a boolean, so {@code ScalarStrategyParamValue} (number, string or boolean) + * matched two or three types for every number and boolean and failed with "N classes match + * result, expected 1": a run started with a numeric or boolean {@code params} value came back + * unreadable, though the platform had run it. Coercion is therefore off, which is also what a + * {@code oneOf} of scalars needs to tell its members apart. + * + *

The mapper has to be installed on the client. {@link ApiClient#getObjectMapper()} + * hands out a copy on every call, so configuring what it returns changes nothing; and the API + * classes take their mapper from the client when they are built, so this is done before any of + * them is. + */ +public final class ApiClients { + + private ApiClients() { + } + + /** A client whose mapper does not coerce scalars between JSON types. */ + @SuppressWarnings("deprecation") + public static ApiClient create() { + ApiClient client = new ApiClient(); + client.setObjectMapper( + ApiClient.createDefaultObjectMapper().configure(MapperFeature.ALLOW_COERCION_OF_SCALARS, false)); + return client; + } +} diff --git a/src/test/java/com/qtsurfer/api/sdk/BacktestResultTest.java b/src/test/java/com/qtsurfer/api/sdk/BacktestResultTest.java index e829680..b49ab7b 100644 --- a/src/test/java/com/qtsurfer/api/sdk/BacktestResultTest.java +++ b/src/test/java/com/qtsurfer/api/sdk/BacktestResultTest.java @@ -85,6 +85,28 @@ void backtestResultGetsTheExecuteJobResource() { assertEquals(7L, result.getTotalTrades()); } + /** + * A run started with per-run {@code params} comes back with them echoed. A number or a boolean + * among them used to make the whole result unreadable ("N classes match result, expected 1"). + */ + @Test + void aRunGivenNumericAndBooleanParamsIsReadable() { + responseStatus.set(200); + responseBody.set(""" + {"state":{"status":"Completed"},"results":{"instrument":"BTC/USDT","totalTrades":72,\ + "params":{"cycle.seconds":20,"threshold":0.5,"enabled":true,"label":"x"}}}"""); + + BacktestOutcome outcome = qts.backtestResult("binance", "exec-1"); + + ResultMap result = assertInstanceOf(BacktestOutcome.Completed.class, outcome).results(); + assertEquals(72L, result.getTotalTrades()); + assertEquals(4, result.getParams().size()); + assertEquals(0, new java.math.BigDecimal("20") + .compareTo((java.math.BigDecimal) result.getParams().get("cycle.seconds").getActualInstance())); + assertEquals(Boolean.TRUE, result.getParams().get("enabled").getActualInstance()); + assertEquals("x", result.getParams().get("label").getActualInstance()); + } + /** * The reason this returns a sealed outcome rather than the numbers alone. * A run that failed is the answer to "what happened to this job?", so it diff --git a/src/test/java/com/qtsurfer/api/sdk/auth/AuthenticatedClientTest.java b/src/test/java/com/qtsurfer/api/sdk/auth/AuthenticatedClientTest.java index b4c1a54..a724311 100644 --- a/src/test/java/com/qtsurfer/api/sdk/auth/AuthenticatedClientTest.java +++ b/src/test/java/com/qtsurfer/api/sdk/auth/AuthenticatedClientTest.java @@ -49,6 +49,8 @@ class AuthenticatedClientTest { private final AtomicInteger validateCalls = new AtomicInteger(); private final List resultStatuses = new ArrayList<>(); private final AtomicInteger resultCalls = new AtomicInteger(); + private final AtomicReference resultBody = new AtomicReference<>( + "{\"state\":{\"status\":\"Completed\"},\"results\":{\"pnlTotal\":42.5}}"); private final List compileStatuses = new ArrayList<>(); private final AtomicInteger compileCalls = new AtomicInteger(); @@ -105,7 +107,7 @@ void start() throws IOException { ? resultStatuses.get(idx) : resultStatuses.get(resultStatuses.size() - 1); body = (status == 200 - ? "{\"state\":{\"status\":\"Completed\"},\"results\":{\"pnlTotal\":42.5}}" + ? resultBody.get() : "{\"error\":\"" + status + "\"}").getBytes(StandardCharsets.UTF_8); ctype = "application/json"; } else if (path.endsWith("/strategy")) { @@ -530,6 +532,25 @@ void backtestResultParticipatesInRefreshOn401() { assertEquals("Bearer jwt-2", retried.authorization()); } + /** The session the MCP server uses must read a result whose echoed params hold a number or a boolean. */ + @Test + void backtestResultReadsNumericAndBooleanParamsEchoedBack() { + tokenResponses.add(jwt("jwt-1", "free")); + resultStatuses.add(200); + resultBody.set(""" + {"state":{"status":"Completed"},"results":{"totalTrades":72,\ + "params":{"cycle.seconds":20,"enabled":true,"label":"x"}}}"""); + + AuthenticatedClient session = QTSurfer.authenticate("ak", opts()); + BacktestOutcome outcome = session.backtestResult("binance", "exec-1"); + + ResultMap result = assertInstanceOf(BacktestOutcome.Completed.class, outcome).results(); + assertEquals(72L, result.getTotalTrades()); + assertEquals(3, result.getParams().size()); + assertEquals(Boolean.TRUE, result.getParams().get("enabled").getActualInstance()); + assertEquals("x", result.getParams().get("label").getActualInstance()); + } + @Test void segmentInstrumentsHitsTheSegmentPathAndUnwrapsTheEnvelope() { tokenResponses.add(jwt("jwt-1", "free")); diff --git a/src/test/java/com/qtsurfer/api/sdk/internal/ApiClientsTest.java b/src/test/java/com/qtsurfer/api/sdk/internal/ApiClientsTest.java new file mode 100644 index 0000000..fe00104 --- /dev/null +++ b/src/test/java/com/qtsurfer/api/sdk/internal/ApiClientsTest.java @@ -0,0 +1,65 @@ +package com.qtsurfer.api.sdk.internal; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.qtsurfer.api.client.model.ResultMap; +import com.qtsurfer.api.client.model.ScalarStrategyParamValue; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +/** + * The generated client reads {@code ScalarStrategyParamValue} (number, string or boolean) by + * requiring exactly one member to accept the JSON value. With scalar coercion on, a number also + * fits a string and a boolean, and a run given a numeric or boolean {@code params} value could + * not be read back. + */ +class ApiClientsTest { + + private final ObjectMapper mapper = ApiClients.create().getObjectMapper(); + + private Object read(String json) throws Exception { + return mapper.readValue(json, ScalarStrategyParamValue.class).getActualInstance(); + } + + @Test + void aWholeNumberIsANumber() throws Exception { + BigDecimal value = assertInstanceOf(BigDecimal.class, read("20")); + assertEquals(0, new BigDecimal("20").compareTo(value)); + } + + @Test + void aDecimalIsANumber() throws Exception { + BigDecimal value = assertInstanceOf(BigDecimal.class, read("20.5")); + assertEquals(0, new BigDecimal("20.5").compareTo(value)); + } + + @Test + void aBooleanIsABoolean() throws Exception { + assertEquals(Boolean.TRUE, assertInstanceOf(Boolean.class, read("true"))); + assertEquals(Boolean.FALSE, assertInstanceOf(Boolean.class, read("false"))); + } + + @Test + void aStringIsAStringEvenWhenItLooksLikeANumberOrABoolean() throws Exception { + assertEquals("abc", assertInstanceOf(String.class, read("\"abc\""))); + assertEquals("20", assertInstanceOf(String.class, read("\"20\""))); + assertEquals("true", assertInstanceOf(String.class, read("\"true\""))); + } + + @Test + void aResultThatEchoesMixedParamsIsReadable() throws Exception { + ResultMap result = mapper.readValue(""" + {"instrument":"BTC/USDT","totalTrades":72, + "params":{"cycle.seconds":20,"threshold":0.5,"enabled":true,"label":"x"}}""", ResultMap.class); + + var params = result.getParams(); + assertEquals(4, params.size()); + assertInstanceOf(BigDecimal.class, params.get("cycle.seconds").getActualInstance()); + assertInstanceOf(BigDecimal.class, params.get("threshold").getActualInstance()); + assertEquals(Boolean.TRUE, params.get("enabled").getActualInstance()); + assertEquals("x", params.get("label").getActualInstance()); + } +}