Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ✨
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/com/qtsurfer/api/sdk/QTSurfer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1414,7 +1415,7 @@ private void mirror() {
}

private static ApiClient buildApiClient(AuthOptions opts, Consumer<HttpRequest.Builder> headers) {
ApiClient c = new ApiClient();
ApiClient c = ApiClients.create();
c.updateBaseUri(opts.baseUrl().toString());
c.setRequestInterceptor(headers);
return c;
Expand Down
35 changes: 35 additions & 0 deletions src/main/java/com/qtsurfer/api/sdk/internal/ApiClients.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>The mapper has to be <em>installed</em> 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;
}
}
22 changes: 22 additions & 0 deletions src/test/java/com/qtsurfer/api/sdk/BacktestResultTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class AuthenticatedClientTest {
private final AtomicInteger validateCalls = new AtomicInteger();
private final List<Integer> resultStatuses = new ArrayList<>();
private final AtomicInteger resultCalls = new AtomicInteger();
private final AtomicReference<String> resultBody = new AtomicReference<>(
"{\"state\":{\"status\":\"Completed\"},\"results\":{\"pnlTotal\":42.5}}");
private final List<Integer> compileStatuses = new ArrayList<>();
private final AtomicInteger compileCalls = new AtomicInteger();

Expand Down Expand Up @@ -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")) {
Expand Down Expand Up @@ -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"));
Expand Down
65 changes: 65 additions & 0 deletions src/test/java/com/qtsurfer/api/sdk/internal/ApiClientsTest.java
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading