Opinionated Java SDK for QTSurfer, built on top of com.qtsurfer:api-client.
com.qtsurfer:sdk-java
Where com.qtsurfer:api-client-java gives you one method per endpoint, this package adds workflow orchestration, normalized errors, and cancellation β run a backtest, or a whole parameter sweep, from a single call. What it reaches of the API is listed in API coverage.
- Powered by
java.net.http.HttpClient(JDK built-in) via the transitive client. - Retry/backoff/timeout delegated to Failsafe β no hand-rolled polling loops.
- SLF4J 2.x API (no binding shipped β consumers bring their own).
- JDK 17+.
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependency>
<groupId>com.qtsurfer</groupId>
<artifactId>sdk-java</artifactId>
<version>x.x.x</version>
</dependency>The transitive com.qtsurfer:api-client-java and dev.failsafe:failsafe come along automatically.
Once published to Central, the coordinate will be com.qtsurfer:sdk-java:x.x.x.
One call: API key in, ready-to-use session out. JWT refresh on 401 is handled for you.
import com.qtsurfer.api.client.model.ResultMap;
import com.qtsurfer.api.sdk.BacktestRequest;
import com.qtsurfer.api.sdk.QTSurfer;
import com.qtsurfer.api.sdk.auth.AuthenticatedClient;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture;
// Reads QTSURFER_APIKEY from env when no argument is passed.
AuthenticatedClient qts = QTSurfer.authenticate();
// Or: AuthenticatedClient qts = QTSurfer.authenticate("ak_...");
ResultMap result = qts.backtest(
BacktestRequest.builder()
.strategy(Files.readString(Path.of("Strategy.java")))
.exchangeId("binance")
.instrument("BTC/USDT")
.from("2026-04-13T00:00:00Z")
.to("2026-04-14T00:00:00Z")
.storeSignals(true)
.build()).join();
System.out.println("PnL: " + result.getPnlTotal());
System.out.println("Trades: " + result.getTotalTrades());| Variable | Purpose |
|---|---|
QTSURFER_APIKEY |
API key consumed by QTSurfer.authenticate() when no arg is passed |
Tokens are kept in memory by default. Implement TokenStore to back
tokens by an on-disk file, a secret manager, or a desktop keychain:
import com.qtsurfer.api.client.model.AuthTokenResponse;
import com.qtsurfer.api.sdk.QTSurfer;
import com.qtsurfer.api.sdk.auth.AuthOptions;
import com.qtsurfer.api.sdk.auth.TokenStore;
TokenStore fileStore = new TokenStore() {
@Override public AuthTokenResponse load() { /* read from disk */ return null; }
@Override public void save(AuthTokenResponse t) { /* write to disk */ }
@Override public void clear() { /* delete the file */ }
};
var qts = QTSurfer.authenticate(null,
AuthOptions.builder().store(fileStore).build());AuthOptions also accepts baseUrl, httpClient, and executor for
staging targets, custom transports, and dedicated thread pools.
If you already hold a JWT and want to manage refresh yourself, the
QTSurfer.builder() path is unchanged:
import com.qtsurfer.api.sdk.QTSurfer;
QTSurfer qts = QTSurfer.builder()
.baseUrl("https://api.qtsurfer.com/v1")
.token(System.getenv("JWT_API_TOKEN"))
.build();import com.qtsurfer.api.client.model.ResultMap;
import com.qtsurfer.api.sdk.BacktestOptions;
import com.qtsurfer.api.sdk.BacktestRequest;
import com.qtsurfer.api.sdk.QTSurfer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
QTSurfer qts = QTSurfer.builder()
.baseUrl("https://api.qtsurfer.com/v1")
.token(System.getenv("JWT_API_TOKEN"))
.build();
CompletableFuture<ResultMap> future = qts.backtest(
BacktestRequest.builder()
.strategy(Files.readString(Path.of("Strategy.java")))
.exchangeId("binance")
.instrument("BTC/USDT")
.from("2026-04-13T00:00:00Z")
.to("2026-04-14T00:00:00Z")
.storeSignals(true)
.build(),
BacktestOptions.builder()
.onProgress(p -> System.out.printf("[%s] %s%n",
p.stage(),
p.percent() != null ? String.format("%.1f%%", p.percent()) : ""))
.pollInterval(Duration.ofMillis(500))
.maxPollInterval(Duration.ofSeconds(5))
.timeout(Duration.ofMinutes(10))
.build());
ResultMap result = future.join();
System.out.println("PnL: " + result.getPnlTotal());
System.out.println("Trades: " + result.getTotalTrades());qts.backtest(req) is a shortcut for compile β backtest β await. When you want
the intermediate handles β to reuse a compiled strategy, subscribe to progress as
a reactive stream, or cancel mid-run β use them directly:
import com.qtsurfer.api.sdk.Backtest;
import com.qtsurfer.api.sdk.Strategy;
Strategy strategy = qts.compile(request).join();
Backtest job = strategy.backtest(request, options).join();
job.progress().subscribe(/* a Flow.Subscriber<BacktestProgress> */);
ResultMap result = job.await().join();Backtest exposes id(), state(), progress() (a Flow.Publisher<BacktestProgress>),
await(), and cancel() (best-effort server-side cancelBacktest).
Measured against API spec 0.109.1: 21 operations, all 21 reachable from this SDK.
It exists because the generated com.qtsurfer:api-client-java tracks the spec automatically and
this hand-written layer does not. Unqualified method names below are on both entry points,
QTSurfer and AuthenticatedClient, whose surfaces mirror each other; authenticate is the
exception, since it is the auth path itself.
| Operation | Reached by |
|---|---|
authenticate |
Direct β QTSurfer.authenticate(); AuthenticatedClient.refresh() forces a re-mint |
listExchanges |
Direct β exchanges() |
listInstruments |
Direct β instruments(exchangeId) |
listSegmentInstruments |
Direct β instruments(exchangeId, segment) |
downloadTickers |
Direct β tickers(...) |
downloadKlines |
Direct β klines(...) |
compileStrategy |
Direct β compile(...) β Strategy |
validateStrategy |
Direct β validateStrategy(strategyId) β ValidationOutcome |
getStrategy |
Direct β strategyState(strategyId) β StrategyState |
listStrategies |
Direct β listStrategies() β List<StrategySummary> |
deleteStrategy |
Direct β deleteStrategy(strategyId) |
getStrategyCode |
Direct β getStrategyCode(strategyId) β String |
prepareBacktest |
Via workflow β backtest(...) and sweep(...) |
getPrepareStatus |
Via workflow β the prepare poll |
executeBacktest |
Via workflow β backtest(...) |
getBacktestResult |
Via workflow (the execute poll behind Backtest.await()) and direct β backtestResult(exchangeId, jobId) reads a run this process did not start |
cancelBacktest |
Direct β Backtest.cancel() |
executeSweep |
Via workflow β sweep(...) |
getSweepResult |
Via workflow (the leaderboard poll) and direct β Sweep.results(...) re-reads it under another view |
cancelSweep |
Direct β Sweep.cancel() |
getSweepSensitivity |
Direct β Sweep.sensitivity(...) |
"Via workflow" means the operation runs as a stage of backtest(...), sweep(...), or the
decomposed Strategy.backtest(...), and has no standalone method β deliberately. Those calls own
the dataset lifecycle: preparing answers with the job id of the prepared window, and that id is
what the execute and sweep endpoints then consume. A standalone prepare() would hand it to the
caller to hold and pass on to every later call. Preparing is idempotent β the same instrument and
window always resolve to the same job β so a workflow that prepares on every call duplicates no
work, and the id never has to leave it. Not a gap.
The two rows reading "via workflow β¦ and direct" are not exceptions to that. Reading a
leaderboard, or the result of a run that already exists, is not a stage of a run you are
performing: it is a query against a resource the platform already holds, addressed by ids you were
handed rather than ids a workflow has to keep alive. That is why Sweep.results(...) and
backtestResult(...) are standalone while prepare() and executeBacktest() are not.
Maintenance contract. When the spec gains an operation, it gains a row here. If this layer deliberately does not wrap it, the row says so and why.
Orchestrates the four-step workflow exposed by the raw API:
- Compile the strategy (
POST /strategy), which answers synchronously with thestrategyId. - Prepare the data range (
POST /backtest/{exchange}/ticker/prepare) and pollGET β¦/prepare/{jobId}untilCompleted. - Execute the backtest (
POST /backtest/{exchange}/ticker/execute) and pollGET β¦/execute/{jobId}untilCompleted. - Resolve the returned
CompletableFuturewith theResultMap(pnlTotal,totalTrades,sharpeRatio,signalsUrl, β¦).
Polling uses Failsafe RetryPolicy with exponential backoff (initial β max, capped) plus an optional Timeout per stage.
Progress is emitted:
- On every stage transition (
percent == null). - After each poll where the backend reports
size > 0(percentin 0β100).
The Backtest handle only exists in the process that submitted the run. When a job id reaches you
from somewhere else β another client, another session, the same process before a restart β ask the
platform directly:
import com.qtsurfer.api.sdk.BacktestOutcome;
BacktestOutcome outcome = qts.backtestResult("binance", "5f3cβ¦");
if (outcome instanceof BacktestOutcome.Completed c) {
System.out.println("PnL: " + c.results().getPnlTotal());
} else if (outcome instanceof BacktestOutcome.Failed f) {
System.out.println("Failed: " + f.state().getStatusDetail());
} else if (outcome instanceof BacktestOutcome.Aborted) {
System.out.println("Cancelled");
} else {
System.out.println("Still running; ask again later");
}One read, nothing submitted. A run that ended badly is an answer here, not an exception β the
opposite of Backtest.await(), which raises on a failed or aborted run. Someone waiting for a
result they asked for is not getting one; someone asking what happened to this job is, so
BacktestOutcome carries the four cases (finished, failed, cancelled, not yet) as values and only
an id the platform does not recognise raises. finished() collapses the first three when the
distinction does not matter.
exchangeId is required and is not defaulted: a run's result is addressed under the exchange it
was submitted against, so a job id on its own does not identify it. To wait for a run you started
yourself, use Backtest.await() instead β this is a snapshot and does not poll.
Before spending a backtest on it, ask the platform to check that a compiled strategy can actually run: the class is instantiated and driven through a bounded synthetic series, so a wiring fault surfaces here instead of on your first run.
The call is idempotent: it either queues a check, or queues nothing because the current compilation
is already accounted for. ValidationOutcome keeps those two answers apart.
import com.qtsurfer.api.sdk.ValidationOutcome;
Strategy strategy = qts.compile(source).join();
ValidationOutcome outcome = qts.validateStrategy(strategy.id());
if (outcome instanceof ValidationOutcome.NotQueued nq) {
// This call started nothing β `nq.state()` is what the platform already holds.
log.info("Nothing queued; state is {}", nq.state().getValidation());
} else {
// This call started a check. Nothing to read yet.
log.info("Queued a check for {}", outcome.strategyId());
}ValidationOutcome is a sealed interface permitting exactly Queued and NotQueued, so on Java 21
and later you can switch over it exhaustively without a default.
NotQueued does not mean a verdict exists. These are two independent questions:
outcome.queued() answers did this call start work?, and validation answers is there a verdict
right now? A NotQueued state can itself be pending β a check that an earlier call, possibly
from another process, already started and that has not answered.
So read the verdict either way:
import com.qtsurfer.api.client.model.StrategyState;
StrategyState state = qts.strategyState(strategy.id());
switch (state.getValidation()) {
case PASSED -> log.info("Loaded and survived its first event");
case FAILED -> log.error("Validation failed: {}", state.getDetail());
case PENDING -> log.warn("Still running β re-read later, this is not a verdict");
case NOT_VALIDATED -> log.warn("Registered but never checked");
}
// `notices` is absent, not empty, when the run surfaced nothing.
if (state.getNotices() != null) {
state.getNotices().forEach(n -> log.info("{} {} β {}", n.getLevel(), n.getCode(), n.getMessage()));
}Re-read on your own schedule until validation leaves pending, bounded by a deadline of your own:
a queued check can stall (validationStalled) and is not guaranteed to resolve. The SDK ships no
polling helper.
passed is a floor, not a guarantee. It means the class loaded and survived the first event of
a short synthetic run β not that the strategy is correct, and not that it is safe to run. When
dryRunIncomplete is true the check did not even finish its budget, so an empty notices list is
not a clean bill of health.
qts.strategyState(strategyId) also reports what market data the compiled class needs
(requiredSources) and when the live compilation was produced (compiledAt). A verdict is
superseded by recompilation: when compiledAt is later than validatedAt, the recorded verdict
describes bytecode that is no longer what would run β call validateStrategy again. HTTP errors
surface as QTSError; a 404 means only that no such strategy is registered for you.
import com.qtsurfer.api.client.model.StrategySummary;
List<StrategySummary> mine = qts.listStrategies();listStrategies() returns every strategy registered under the account and not since deleted,
most recently compiled first β never a 404; an empty list just means none are registered. It
deliberately omits validation state to stay cheap no matter how many strategies exist: check a
specific one with strategyState(strategyId).
String source = qts.getStrategyCode(strategy.id());getStrategyCode(strategyId) returns the exact source last submitted for that id. Its 404
covers two situations without distinguishing them β the id was never registered by the caller, or
it resolves only through a shared/marketplace reference that carries no source of its own. Both
read as the same honest "nothing to return."
qts.deleteStrategy(strategy.id());deleteStrategy(strategyId) releases a registration β three things worth knowing before calling
it:
- Not "undelete." Recompiling the identical source afterward registers a brand-new strategy with a brand-new id.
- History is untouched. Backtests already run against this strategy keep their results; deletion only stops it counting against the account and stops future validation or re-run under this id.
- Scoped to your own registration. Deleting your copy of a strategy never affects anyone else's copy of the same source β for example a shared/marketplace listing.
Run the same strategy once per parameter vector over one prepared window, and read the
leaderboard. One call absorbs the same stages as backtest() β compile β prepare β submit β
because the sweep endpoint is addressed by the id of an already-prepared dataset; preparing is
idempotent, so sweeping the same window twice prepares it once.
import com.qtsurfer.api.sdk.*;
import com.qtsurfer.api.client.model.ExecuteSweepResult;
SweepRequest request = SweepRequest.builder()
.strategy(source)
.exchangeId("binance")
.instrument("BTC/USDT")
.from("2026-01-01T00:00:00Z")
.to("2026-02-01T00:00:00Z")
.param("rsiPeriod", ParamAxis.range(7, 28, 1))
.param("useTrendFilter", ParamAxis.of(true, false))
.objective(SweepObjective.SHARPE)
.build();
Sweep sweep = qts.sweep(request).join();
// Available immediately, before a single trial has run:
sweep.accepted().getSeed(); // effective seed β resubmit it to replay a sampled sweep
sweep.accepted().getQueued(); // false: an identical sweep already existed, nothing was enqueued
sweep.accepted().getTotalRuns(); // size of the expanded grid
ExecuteSweepResult result = sweep.await().join();Sweep exposes id(), requestId(), strategyId(), accepted(), state(), progress()
(a Flow.Publisher<SweepProgressEvent>), await(), results(...), cancel(), and
sensitivity().
The default order is not the raw objective order. It is plateau order: a point's plateau
score is the objective of the worst run in its immediate neighbourhood, so a point ranks well only
if the region around it does too β the highest raw score is very often a spike that does not
survive the parameters moving slightly. Pass SweepOptions.builder().ranking(SweepRanking.RAW) for
the unadjusted order.
What you asked for is not always what was applied: read result.getRanking(). A sweep with no
stored parameter grid cannot be plateau-ranked and falls back to raw.
plateauScoreandneighbourCountare read together.neighbourCount == 0means the point had no neighbours to compare against, so its plateau score is unevidenced, not confirmed.deflatedSharpeβ probability that a row's Sharpe reflects real edge rather than the best draw from however many vectors were tried. Absent on aborted runs and on sweeps with too few trials to deflate against.pboβ probability of backtest overfitting for the sweep as a whole. Above ~0.5 the sweep is selecting noise, which discredits the top row however good it looks. Absent while the sweep is still running and on sweeps too small for the statistic to mean anything.PARTIALis a terminal status, not a "still going" one: at least one unit of work died and its runs are missing. There is no sweep-wide failed status, so a sweep whose every shard died isPARTIALwithleaderboardSize == 0.
The ranked view is a display view: sorted and capped, with result.getTruncated() reporting when
the cap bit. SweepOrder.NATURAL returns every available row instead, untruncated, in
deterministic runIx order β the view to read when materialising durable trial rows, and the route
to rows the ranked view dropped.
The view is a query parameter on the read, so switching it costs one request. Sweep.results(...)
re-reads the sweep you already have β no compile, no prepare, no second sweep:
ExecuteSweepResult ranked = sweep.await().join();
if (Boolean.TRUE.equals(ranked.getTruncated())) {
ExecuteSweepResult all = sweep.results(SweepOrder.NATURAL); // every row, one GET
}sweep.results(order, ranking) takes both; null means the platform default for either. Use
SweepOptions.order(...) instead when you want the polled view β the one await() resolves
with β to be the natural one from the start.
Works on a sweep still in flight too, returning the rows finished so far.
ranking is ignored on the natural view: its order is always runIx, and the response comes
back raw. Rank, plateau score and neighbour count belong to the ranked view and are not part of
it.
Attaching walkForward changes what the sweep does, not just how much of it runs: the data is cut
into sequential folds, each fold optimizes the whole grid on its own window and scores only its
winner on the window immediately after. It answers "does re-optimizing this periodically actually
work" rather than "which parameters won", and costs folds Γ grid.
SweepRequest wf = SweepRequest.builder()
// β¦
.walkForward(WalkForwardSpec.of(4, 70)) // 4 folds, 70 % of each spent optimizing
.build();result.getWalkForward() is the discriminator, and it is present from acceptance onward β also on
sweep.accepted().getWalkForward() β so you can branch on the answer's shape while still polling.
Its leaderboard is one row per completed fold: that fold's winner as it scored out-of-sample,
with runIx carrying the fold index rather than a grid position. No plateau, DSR or PBO figure
is reported for one. paramDrift absent is not zero β the field is omitted when it could not be
computed, and zero is itself a meaningful reading (winners that never moved), so a placeholder
would be indistinguishable from perfect stability.
SweepProgressEvent.snapshot() carries the platform's progress record on every EXECUTING event.
Two of its counts are easy to add together by mistake: aborted counts individual runs that
executed and aborted, while failedShards counts whole units of work that failed and never
reported anything β a shard that dies before producing a row leaves aborted at zero, which is
exactly why the second count exists. retrying is not a failure count either. etaSeconds is
omitted, never zero when it cannot be computed, and runs conservative when present.
How the objective moves as each parameter moves β the question a leaderboard cannot answer, since a sweep can spend its whole budget on an axis that never moved the objective at all.
SweepSensitivity s = sweep.sensitivity(); // the sweep's own objective
SweepSensitivity r = sweep.sensitivity(SweepObjective.SORTINO);
if (Boolean.TRUE.equals(s.getHeatmapsTruncated())) {
// at least one two-parameter surface was left out β this is not the full interaction set
}Marginals are always complete; the pair surfaces are quadratic in the axis count and may be capped,
which is what heatmapsTruncated reports. Readable while the sweep is still running, in which case
the aggregates cover the rows finished so far (rowsAnalysed). Aborted runs are excluded
throughout β a run that threw measured nothing.
Stream one hour of raw ticker or kline data for an instrument. The default wire format is
Lastra (application/vnd.lastra); pass
DownloadFormat.PARQUET for on-the-fly Parquet conversion.
import com.qtsurfer.api.sdk.DownloadFormat;
// Lastra (default), streamed straight to disk
try (var in = qts.tickers("binance", "BTC", "USDT", "2026-01-15T10")) {
Files.copy(in, Path.of("BTC_USDT_2026-01-15_h10.lastra"));
}
// Parquet
try (var in = qts.klines("binance", "BTC", "USDT", "2026-01-15T10", DownloadFormat.PARQUET)) {
// feed into Apache Parquet, DuckDB, etc.
}The caller closes the stream. HTTP errors surface as QTSDownloadError (subclass of QTSError).
List available exchanges and the instruments (with per-data-type coverage windows) for a given exchange.
import com.qtsurfer.api.client.model.CoverageWindow;
import com.qtsurfer.api.client.model.Exchange;
import com.qtsurfer.api.client.model.InstrumentDetail;
// List exchanges
List<Exchange> exchanges = qts.exchanges();
exchanges.forEach(e -> System.out.println(e.getId() + " β " + e.getName()));
// β binance β Binance
// β binancefutures β Binance Futures
// List instruments for an exchange
List<InstrumentDetail> instruments = qts.instruments("binance");
instruments.forEach(i -> {
CoverageWindow window = i.getCoverage() == null
? null
: i.getCoverage().getTickers() != null ? i.getCoverage().getTickers() : i.getCoverage().getKlines();
System.out.printf("%s data: %s β %s last: %.2f%n",
i.getId(),
window == null ? "n/a" : window.getFrom().toLocalDate(),
window == null ? "n/a" : window.getTo().toLocalDate(),
i.getLastPrice());
});instruments(exchangeId) is the default-segment shortcut and lists spot. Pass a segment
explicitly to list another one:
List<InstrumentDetail> perps = qts.instruments("binancefutures", "futures");Both overloads unwrap the same HAL envelope and return List<InstrumentDetail>.
HTTP errors surface as QTSError. Responses reflect live platform state β no client-side cache.
All SDK errors extend QTSError (a RuntimeException) and surface as the cause of the CompletionException wrapping them when the future fails.
try {
qts.backtest(req).join();
} catch (CompletionException e) {
Throwable cause = e.getCause();
if (cause instanceof QTSStrategyCompileError x) log.error("Compile failed: {}", x.getMessage());
else if (cause instanceof QTSPreparationError x) log.error("Data prep failed: {}", x.getMessage());
else if (cause instanceof QTSExecutionError x) log.error("Execution failed: {}", x.getMessage());
else if (cause instanceof QTSDownloadError x) log.error("Download failed: {}", x.getMessage());
else if (cause instanceof QTSTimeoutError x) log.error("Stage timed out: {}", x.getMessage());
else if (cause instanceof QTSCanceledError) log.error("Canceled");
else throw e;
}Written as an instanceof chain because this SDK targets JDK 17, where pattern matching for
switch is still a preview feature. On 21 or newer the same branches read better as a switch.
Cancelling an in-flight backtest goes through the Backtest handle:
Backtest job = strategy.backtest(req, opts).join();
job.cancel();That stops polling immediately and, if the execute stage has already started server-side,
best-effort calls cancelBacktest on the backend.
Cancelling the future from the backtest(...) shortcut does not stop the run. The shortcut
composes its stages with thenCompose, and cancelling a composed CompletableFuture does not
propagate back through the chain β so future.cancel(true) ends your wait and leaves the backtest
running server-side, still billing. This is a property of how the shortcut is built, not an
oversight to work around: if you need to stop the work rather than stop waiting for it, use the
decomposed API above, which hands you the handle.
A sweep cancels differently, on purpose. Sweep.cancel() asks the platform to stop between
parameter vectors, and the rows already scored stay readable β so the SDK keeps polling until the
sweep reports CANCELLED and then resolves await() normally, with the partial leaderboard,
rather than raising QTSCanceledError. Check result.getStatus(). That also means cancelling
depends on the platform answering: with no timeout configured, a sweep that never reports
cancelled leaves await() waiting.
dev.failsafe:failsafeβ retry policies with exponential backoff, optional per-stageTimeout,withInterrupt()so thread interruption fromCompletableFuture#cancel(true)propagates cleanly.com.qtsurfer:api-clientβ generated with openapi-generator'snativelibrary; usesjava.net.http.HttpClient, so no OkHttp/Apache HttpClient transitive dependency.StatusNormalizerβ maps the backend's mixed-case status strings (queued,started,completed,failed, β¦) to a stable enum so the retry predicate and terminal checks work regardless of spec drift.
| Command | Description |
|---|---|
mvn verify |
Compile, run unit tests, build jar + sources + javadoc |
mvn -B -Dtest='*IntegrationTest' test |
Run the integration test β requires JWT_API_TOKEN |
mvn clean |
Remove target/ |
Hits the real backend with ForcedTradeStrategy on binance BTC/USDT for the previous UTC day. Controlled by env vars:
JWT_API_TOKENβ required; the test is skipped when absent.QTSURFER_API_URLβ required; the test is skipped when absent.QTSURFER_TEST_VERBOSE=1β optional; stream progress events and the final result through SLF4J.
JWT_API_TOKEN=... QTSURFER_API_URL=... QTSURFER_TEST_VERBOSE=1 mvn -B -Dtest='*IntegrationTest' testWhat the SDK reaches today is the API coverage table; which release added what is in CHANGELOG.md. This section is only what is still missing β nothing here is a missing API operation:
- TTL cache for
exchanges/instruments - Loaders for
signalsUrlParquet intoduckdb-java/lastra-java - Optional reactive adapters (Reactor / RxJava)
Apache-2.0 β see LICENSE.