From fac3f424d804ed7e6480bbb0f9690b40729867d6 Mon Sep 17 00:00:00 2001 From: Manuel Polo Date: Sun, 25 Oct 2026 14:33:08 +0000 Subject: [PATCH] fix(sdk): read a result whose numeric or boolean per-run params are echoed back The generated client reads the number | string | boolean value of a strategy param by requiring exactly one member to accept the JSON scalar. Its mapper coerced scalars between JSON types, so every number matched two or three members and every boolean two: a backtest started with a numeric or boolean params value ran on the platform, but its result failed to deserialize with 'N classes match result, expected 1'. Only string values worked. Scalar coercion is turned off in the client that QTSurfer and authenticate() build. The mapper is installed with setObjectMapper before any API class is built, because getObjectMapper() returns a copy and the API classes take theirs from the client at construction. --- CHANGELOG.md | 10 +++ .../java/com/qtsurfer/api/sdk/QTSurfer.java | 3 +- .../api/sdk/auth/AuthenticatedClient.java | 3 +- .../qtsurfer/api/sdk/internal/ApiClients.java | 35 ++++++++++ .../qtsurfer/api/sdk/BacktestResultTest.java | 22 +++++++ .../api/sdk/auth/AuthenticatedClientTest.java | 23 ++++++- .../api/sdk/internal/ApiClientsTest.java | 65 +++++++++++++++++++ 7 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/qtsurfer/api/sdk/internal/ApiClients.java create mode 100644 src/test/java/com/qtsurfer/api/sdk/internal/ApiClientsTest.java 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()); + } +}