diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac2a953..3964c48 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
+## [0.5.0] — 2026-05-25
+
+### Added
+
+- `QTSurfer.auth(apikey)` (plus `auth()` env-var overload and `auth(apikey, AuthOptions)`) — one-call helper that exchanges a long-lived API key for a short-lived JWT and returns an `AuthenticatedClient`. The returned session mirrors the existing `QTSurfer` surface (`compile`, `backtest`, `exchanges`, `instruments`, `tickers`, `klines`) but transparently refreshes the JWT once on a 401 before retrying.
+- `com.qtsurfer.api.sdk.auth.TokenStore` interface (`load` / `save` / `clear`) and a default `InMemoryTokenStore`. Adopters can plug in a file, secret manager, or desktop keychain. `AuthOptions` is the configuration record (base URL, token store, HTTP client, executor) — defaults to `https://api.qtsurfer.com/v1` and an in-memory store.
+- `QTSURFER_APIKEY` environment variable: read by `QTSurfer.auth(null, ...)` / `QTSurfer.auth()` when no API key argument is passed.
+- `QTSAuthError` (subclass of `QTSError`) raised when no API key is available or the JWT exchange fails.
+
+### Changed
+
+- Bumped `com.qtsurfer:api-client-java` to `0.3.1` (adds `AuthApi`, `AuthTokenResponse`, `AuthTokenError`).
+- `DownloadFormat#wire()` is now `public` so the auth-session can pass the underlying `ExchangeBinaryDownloads.Format` through.
+
## [0.4.1] — 2026-05-17
### Fixed
diff --git a/README.md b/README.md
index 79414f8..d39711d 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ Where `com.qtsurfer:api-client-java` gives you one method per endpoint, this pac
com.qtsurfer
sdk-java
- 0.4.1
+ x.x.x
```
@@ -47,10 +47,87 @@ The transitive `com.qtsurfer:api-client-java` and `dev.failsafe:failsafe` come a
### Maven Central (future)
-Once published to Central, the coordinate will be `com.qtsurfer:sdk:0.4.1`.
+Once published to Central, the coordinate will be `com.qtsurfer:sdk-java:x.x.x`.
## Quick start
+One call: API key in, ready-to-use session out. JWT refresh on 401 is
+handled for you.
+
+```java
+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.auth();
+// Or: AuthenticatedClient qts = QTSurfer.auth("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());
+```
+
+### Environment
+
+| Variable | Purpose |
+| ----------------- | ---------------------------------------------------- |
+| `QTSURFER_APIKEY` | API key consumed by `QTSurfer.auth()` when no arg is passed |
+
+### Pluggable token storage
+
+Tokens are kept in memory by default. Implement `TokenStore` to back
+tokens by an on-disk file, a secret manager, or a desktop keychain:
+
+```java
+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.auth(null,
+ AuthOptions.builder().store(fileStore).build());
+```
+
+`AuthOptions` also accepts `baseUrl`, `httpClient`, and `executor` for
+staging targets, custom transports, and dedicated thread pools.
+
+### Lower-level: hand-managed JWT
+
+If you already hold a JWT and want to manage refresh yourself, the
+`QTSurfer.builder()` path is unchanged:
+
+```java
+import com.qtsurfer.api.sdk.QTSurfer;
+
+QTSurfer qts = QTSurfer.builder()
+ .baseUrl("https://api.qtsurfer.com/v1")
+ .token(System.getenv("JWT_API_TOKEN"))
+ .build();
+```
+
+### Decomposed pipeline (advanced)
+
```java
import com.qtsurfer.api.client.model.ResultMap;
import com.qtsurfer.api.sdk.BacktestOptions;
diff --git a/pom.xml b/pom.xml
index 5c07f93..6926f8e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,8 +5,8 @@
4.0.0
com.qtsurfer
- sdk
- 0.4.1
+ sdk-java
+ 0.5.0
jar
QTSurfer SDK
@@ -48,7 +48,7 @@
UTF-8
UTF-8
- 0.2.0
+ 0.3.1
3.3.2
2.0.16
2.18.2
diff --git a/src/main/java/com/qtsurfer/api/sdk/DownloadFormat.java b/src/main/java/com/qtsurfer/api/sdk/DownloadFormat.java
index dabdc86..133dc7d 100644
--- a/src/main/java/com/qtsurfer/api/sdk/DownloadFormat.java
+++ b/src/main/java/com/qtsurfer/api/sdk/DownloadFormat.java
@@ -22,7 +22,12 @@ public enum DownloadFormat {
this.wire = wire;
}
- ExchangeBinaryDownloads.Format wire() {
+ /**
+ * Internal: the underlying {@code ExchangeBinaryDownloads.Format} used by
+ * the api-client. Exposed so the SDK's own helpers can pass it through
+ * without re-encoding.
+ */
+ public ExchangeBinaryDownloads.Format wire() {
return wire;
}
}
diff --git a/src/main/java/com/qtsurfer/api/sdk/QTSurfer.java b/src/main/java/com/qtsurfer/api/sdk/QTSurfer.java
index a624de9..a5cb195 100644
--- a/src/main/java/com/qtsurfer/api/sdk/QTSurfer.java
+++ b/src/main/java/com/qtsurfer/api/sdk/QTSurfer.java
@@ -8,6 +8,8 @@
import com.qtsurfer.api.client.model.Exchange;
import com.qtsurfer.api.client.model.InstrumentDetail;
import com.qtsurfer.api.client.model.ResultMap;
+import com.qtsurfer.api.sdk.auth.AuthOptions;
+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.HttpStrategyCompileClient;
@@ -173,6 +175,32 @@ private static String describe(ApiException e) {
return "HTTP " + e.getCode();
}
+ /**
+ * One-call setup: exchange an API key for a short-lived JWT and return
+ * an {@link AuthenticatedClient} that mirrors this SDK's surface
+ * (compile / backtest / exchanges / instruments / tickers / klines)
+ * with automatic refresh-on-401.
+ *
+ *
If {@code apikey} is {@code null} or blank, the value is read from
+ * the {@code QTSURFER_APIKEY} environment variable.
+ *
+ * @throws com.qtsurfer.api.sdk.errors.QTSAuthError when no API key is
+ * available or the initial JWT exchange fails.
+ */
+ public static AuthenticatedClient auth(String apikey) {
+ return AuthenticatedClient.auth(apikey);
+ }
+
+ /** Overload accepting an {@link AuthOptions} (base URL, token store, executor). */
+ public static AuthenticatedClient auth(String apikey, AuthOptions options) {
+ return AuthenticatedClient.auth(apikey, options);
+ }
+
+ /** Overload that reads the API key from {@code QTSURFER_APIKEY}. */
+ public static AuthenticatedClient auth() {
+ return AuthenticatedClient.auth();
+ }
+
public static Builder builder() { return new Builder(); }
public static final class Builder {
diff --git a/src/main/java/com/qtsurfer/api/sdk/auth/AuthOptions.java b/src/main/java/com/qtsurfer/api/sdk/auth/AuthOptions.java
new file mode 100644
index 0000000..37ddf37
--- /dev/null
+++ b/src/main/java/com/qtsurfer/api/sdk/auth/AuthOptions.java
@@ -0,0 +1,53 @@
+package com.qtsurfer.api.sdk.auth;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.util.Objects;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * Configuration for {@link AuthenticatedClient}. Mirrors
+ * {@code QTSurferOptions} but replaces the static {@code token} with a
+ * pluggable {@link TokenStore}.
+ *
+ * @param baseUrl API base URL. Defaults to {@code https://api.qtsurfer.com/v1}.
+ * @param store pluggable token store; defaults to {@link InMemoryTokenStore}.
+ * @param httpClient optional custom {@link HttpClient}.
+ * @param executor executor that runs the async workflow.
+ */
+public record AuthOptions(
+ URI baseUrl,
+ TokenStore store,
+ HttpClient httpClient,
+ ExecutorService executor
+) {
+ public static final URI DEFAULT_BASE_URL = URI.create("https://api.qtsurfer.com/v1");
+
+ public AuthOptions {
+ Objects.requireNonNull(baseUrl, "baseUrl");
+ Objects.requireNonNull(store, "store");
+ }
+
+ public static AuthOptions defaults() {
+ return builder().build();
+ }
+
+ public static Builder builder() { return new Builder(); }
+
+ public static final class Builder {
+ private URI baseUrl = DEFAULT_BASE_URL;
+ private TokenStore store = new InMemoryTokenStore();
+ private HttpClient httpClient;
+ private ExecutorService executor;
+
+ public Builder baseUrl(URI baseUrl) { this.baseUrl = baseUrl; return this; }
+ public Builder baseUrl(String baseUrl) { this.baseUrl = URI.create(baseUrl); return this; }
+ public Builder store(TokenStore store) { this.store = store; return this; }
+ public Builder httpClient(HttpClient httpClient) { this.httpClient = httpClient; return this; }
+ public Builder executor(ExecutorService executor) { this.executor = executor; return this; }
+
+ public AuthOptions build() {
+ return new AuthOptions(baseUrl, store, httpClient, executor);
+ }
+ }
+}
diff --git a/src/main/java/com/qtsurfer/api/sdk/auth/AuthenticatedClient.java b/src/main/java/com/qtsurfer/api/sdk/auth/AuthenticatedClient.java
new file mode 100644
index 0000000..e2b0a32
--- /dev/null
+++ b/src/main/java/com/qtsurfer/api/sdk/auth/AuthenticatedClient.java
@@ -0,0 +1,348 @@
+package com.qtsurfer.api.sdk.auth;
+
+import com.qtsurfer.api.client.api.AuthApi;
+import com.qtsurfer.api.client.api.BacktestingApi;
+import com.qtsurfer.api.client.api.ExchangeApi;
+import com.qtsurfer.api.client.binary.ExchangeBinaryDownloads;
+import com.qtsurfer.api.client.invoker.ApiClient;
+import com.qtsurfer.api.client.invoker.ApiException;
+import com.qtsurfer.api.client.model.AuthTokenResponse;
+import com.qtsurfer.api.client.model.Exchange;
+import com.qtsurfer.api.client.model.InstrumentDetail;
+import com.qtsurfer.api.client.model.ResultMap;
+import com.qtsurfer.api.sdk.BacktestOptions;
+import com.qtsurfer.api.sdk.BacktestRequest;
+import com.qtsurfer.api.sdk.DownloadFormat;
+import com.qtsurfer.api.sdk.Strategy;
+import com.qtsurfer.api.sdk.errors.QTSAuthError;
+import com.qtsurfer.api.sdk.errors.QTSDownloadError;
+import com.qtsurfer.api.sdk.errors.QTSError;
+import com.qtsurfer.api.sdk.internal.HttpStrategyCompileClient;
+import com.qtsurfer.api.sdk.workflows.BacktestWorkflow;
+
+import java.io.InputStream;
+import java.net.http.HttpRequest;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ForkJoinPool;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+/**
+ * Authenticated SDK session.
+ *
+ *
Created by {@link com.qtsurfer.api.sdk.QTSurfer#auth(String)} (or the
+ * overload that accepts an {@link AuthOptions}). Wraps the underlying
+ * api-client, owns a JWT (in memory by default, or in the provided
+ * {@link TokenStore}), and transparently re-exchanges the API key for a
+ * fresh JWT when a call returns 401.
+ *
+ *
Exposes the same workflow surface as {@code QTSurfer}: {@code compile},
+ * {@code backtest}, {@code exchanges}, {@code instruments}, {@code tickers},
+ * {@code klines}. Method semantics are unchanged — only the bearer token
+ * management differs.
+ *
+ *
Refresh policy: a 401 from any wrapped call triggers exactly one
+ * {@code POST /v1/auth/token} exchange, then the original call is retried
+ * once. A second 401 is surfaced to the caller.
+ */
+public final class AuthenticatedClient {
+
+ static final String APIKEY_ENV_VAR = "QTSURFER_APIKEY";
+
+ private final AuthOptions options;
+ private final AuthApi authApi;
+ private final BacktestWorkflow backtestWorkflow;
+ private final ExchangeBinaryDownloads downloads;
+ private final ExchangeApi exchangeApi;
+ private final AtomicReference cached = new AtomicReference<>();
+
+ AuthenticatedClient(
+ AuthOptions options,
+ AuthApi authApi,
+ BacktestWorkflow backtestWorkflow,
+ ExchangeBinaryDownloads downloads,
+ ExchangeApi exchangeApi) {
+ this.options = options;
+ this.authApi = authApi;
+ this.backtestWorkflow = backtestWorkflow;
+ this.downloads = downloads;
+ this.exchangeApi = exchangeApi;
+ }
+
+ /** Configuration in use by this session. */
+ public AuthOptions options() { return options; }
+
+ /** Most recently minted token, or {@code null} if no exchange has happened yet. */
+ public AuthTokenResponse token() { return cached.get(); }
+
+ /**
+ * Force a fresh JWT exchange via {@code POST /v1/auth/token}. Bypasses
+ * the cache; the returned token is also written to the configured
+ * {@link TokenStore}.
+ */
+ public synchronized AuthTokenResponse refresh() {
+ AuthTokenResponse fresh;
+ try {
+ fresh = authApi.auth();
+ } catch (ApiException e) {
+ throw new QTSAuthError("auth() failed: HTTP " + e.getCode(), e);
+ }
+ if (fresh == null) {
+ throw new QTSAuthError("auth() returned an empty response");
+ }
+ cached.set(fresh);
+ mirror();
+ options.store().save(fresh);
+ return fresh;
+ }
+
+ /**
+ * Return the cached token, seeding from the {@link TokenStore} on first
+ * use, and minting a new one if neither cache nor store hold one.
+ */
+ public AuthTokenResponse ensureToken() {
+ AuthTokenResponse t = cached.get();
+ if (t != null) return t;
+ AuthTokenResponse stored = options.store().load();
+ if (stored != null) {
+ cached.set(stored);
+ mirror();
+ return stored;
+ }
+ return refresh();
+ }
+
+ /** Drop the cached token (in memory and in the store). */
+ public void clear() {
+ cached.set(null);
+ mirror();
+ options.store().clear();
+ }
+
+ // ---- Workflow surface (mirrors QTSurfer) ----
+
+ public CompletableFuture compile(String source) {
+ return compile(source, BacktestOptions.defaults());
+ }
+
+ public CompletableFuture compile(String source, BacktestOptions opts) {
+ Objects.requireNonNull(source, "source");
+ return withRefreshOn401Async(() -> backtestWorkflow.compile(source, opts));
+ }
+
+ public CompletableFuture compile(BacktestRequest request) {
+ Objects.requireNonNull(request, "request");
+ return compile(request.strategy(), BacktestOptions.defaults());
+ }
+
+ public CompletableFuture compile(BacktestRequest request, BacktestOptions opts) {
+ Objects.requireNonNull(request, "request");
+ return compile(request.strategy(), opts);
+ }
+
+ public CompletableFuture backtest(BacktestRequest request) {
+ return backtest(request, BacktestOptions.defaults());
+ }
+
+ public CompletableFuture backtest(BacktestRequest request, BacktestOptions opts) {
+ Objects.requireNonNull(request, "request");
+ return withRefreshOn401Async(() -> backtestWorkflow.runFull(request, opts));
+ }
+
+ public List exchanges() {
+ return withRefreshOn401(() -> {
+ try {
+ return exchangeApi.getExchanges();
+ } catch (ApiException e) {
+ throw new QTSError("exchanges call failed: " + describe(e), e);
+ }
+ });
+ }
+
+ public List instruments(String exchangeId) {
+ Objects.requireNonNull(exchangeId, "exchangeId");
+ return withRefreshOn401(() -> {
+ try {
+ return exchangeApi.getInstruments(exchangeId);
+ } catch (ApiException e) {
+ throw new QTSError("instruments call failed: " + describe(e), e);
+ }
+ });
+ }
+
+ public InputStream tickers(String exchangeId, String base, String quote, String hour) {
+ return tickers(exchangeId, base, quote, hour, DownloadFormat.LASTRA);
+ }
+
+ public InputStream tickers(String exchangeId, String base, String quote, String hour, DownloadFormat format) {
+ Objects.requireNonNull(format, "format");
+ return withRefreshOn401(() -> {
+ try {
+ return downloads.getTickersHour(exchangeId, base, quote, hour, format.wire());
+ } catch (ApiException e) {
+ throw new QTSDownloadError("tickers download failed: " + describe(e), e);
+ }
+ });
+ }
+
+ public InputStream klines(String exchangeId, String base, String quote, String hour) {
+ return klines(exchangeId, base, quote, hour, DownloadFormat.LASTRA);
+ }
+
+ public InputStream klines(String exchangeId, String base, String quote, String hour, DownloadFormat format) {
+ Objects.requireNonNull(format, "format");
+ return withRefreshOn401(() -> {
+ try {
+ return downloads.getKlinesHour(exchangeId, base, quote, hour, format.wire());
+ } catch (ApiException e) {
+ throw new QTSDownloadError("klines download failed: " + describe(e), e);
+ }
+ });
+ }
+
+ // ---- Refresh-on-401 plumbing ----
+
+ private T withRefreshOn401(Supplier call) {
+ ensureToken();
+ try {
+ return call.get();
+ } catch (QTSError e) {
+ if (!isUnauthorized(e)) throw e;
+ cached.set(null);
+ mirror();
+ refresh();
+ return call.get();
+ }
+ }
+
+ private CompletableFuture withRefreshOn401Async(Supplier> call) {
+ ensureToken();
+ return call.get().handle((value, ex) -> {
+ if (ex == null) return CompletableFuture.completedFuture(value);
+ Throwable cause = unwrap(ex);
+ if (!(cause instanceof QTSError qts) || !isUnauthorized(qts)) {
+ CompletableFuture fail = new CompletableFuture<>();
+ fail.completeExceptionally(ex);
+ return fail;
+ }
+ cached.set(null);
+ mirror();
+ refresh();
+ return call.get();
+ }).thenCompose(f -> f);
+ }
+
+ private static boolean isUnauthorized(QTSError e) {
+ Throwable c = e.getCause();
+ return c instanceof ApiException api && api.getCode() == 401;
+ }
+
+ private static Throwable unwrap(Throwable t) {
+ if (t instanceof CompletionException ce && ce.getCause() != null) {
+ return ce.getCause();
+ }
+ return t;
+ }
+
+ private static String describe(ApiException e) {
+ if (e.getResponseBody() != null && !e.getResponseBody().isBlank()) {
+ return "HTTP " + e.getCode() + " — " + e.getResponseBody();
+ }
+ return "HTTP " + e.getCode();
+ }
+
+ // ---- Factory ----
+
+ /**
+ * Mint a fresh session from the given API key.
+ *
+ * If {@code apikey} is {@code null} or blank, the value is read from
+ * the {@code QTSURFER_APIKEY} environment variable. A {@link QTSAuthError}
+ * is raised when neither source yields a usable API key, or when the
+ * initial JWT exchange fails.
+ */
+ public static AuthenticatedClient auth(String apikey, AuthOptions opts) {
+ String resolved = resolveApikey(apikey);
+ AuthOptions o = opts != null ? opts : AuthOptions.defaults();
+
+ // ApiClient for the mint call — interceptor pins X-API-Key.
+ ApiClient mintClient = buildApiClient(o, b -> b.header("X-API-Key", resolved));
+ AuthApi mintApi = new AuthApi(mintClient);
+
+ // Container ref shared between the request interceptor and the
+ // session itself, so refresh() swapping the cached token is visible
+ // to subsequent api-client calls without rebuilding the client.
+ AtomicReference shared = new AtomicReference<>();
+ ApiClient apiClient = buildApiClient(o, b -> {
+ AuthTokenResponse t = shared.get();
+ if (t != null && t.getAccessToken() != null) {
+ b.header("Authorization", "Bearer " + t.getAccessToken());
+ }
+ });
+
+ BacktestingApi backtestingApi = new BacktestingApi(apiClient);
+ ExecutorService exec = o.executor() != null ? o.executor() : ForkJoinPool.commonPool();
+ BacktestWorkflow workflow = new BacktestWorkflow(
+ new HttpStrategyCompileClient(apiClient), backtestingApi, exec);
+ ExchangeBinaryDownloads downloads = new ExchangeBinaryDownloads(apiClient);
+ ExchangeApi exchangeApi = new ExchangeApi(apiClient);
+
+ AuthenticatedClient session = new AuthenticatedClient(
+ o, mintApi, workflow, downloads, exchangeApi);
+ // Keep `shared` mirrored to the session's cache via a bridge thread-safely.
+ session.linkBearerRef(shared);
+
+ // Initial mint so the session is usable immediately.
+ session.ensureToken();
+ return session;
+ }
+
+ public static AuthenticatedClient auth(String apikey) {
+ return auth(apikey, AuthOptions.defaults());
+ }
+
+ public static AuthenticatedClient auth() {
+ return auth(null, AuthOptions.defaults());
+ }
+
+ /**
+ * Wire the bearer-token interceptor's reference to this session's cache.
+ * Called by the factory.
+ */
+ private void linkBearerRef(AtomicReference bearerRef) {
+ this.bearerRef = bearerRef;
+ AuthTokenResponse current = cached.get();
+ if (current != null) bearerRef.set(current);
+ }
+
+ private volatile AtomicReference bearerRef;
+
+ private void mirror() {
+ AtomicReference r = bearerRef;
+ if (r != null) r.set(cached.get());
+ }
+
+ private static ApiClient buildApiClient(AuthOptions opts, Consumer headers) {
+ ApiClient c = new ApiClient();
+ c.updateBaseUri(opts.baseUrl().toString());
+ c.setRequestInterceptor(headers);
+ return c;
+ }
+
+ private static String resolveApikey(String explicit) {
+ return resolveApikey(explicit, System.getenv(APIKEY_ENV_VAR));
+ }
+
+ /** Package-private overload exposed for unit tests. */
+ static String resolveApikey(String explicit, String envValue) {
+ if (explicit != null && !explicit.isBlank()) return explicit;
+ if (envValue != null && !envValue.isBlank()) return envValue;
+ throw new QTSAuthError(
+ "auth() requires an apikey (argument or " + APIKEY_ENV_VAR + " env var)");
+ }
+}
diff --git a/src/main/java/com/qtsurfer/api/sdk/auth/InMemoryTokenStore.java b/src/main/java/com/qtsurfer/api/sdk/auth/InMemoryTokenStore.java
new file mode 100644
index 0000000..7b0db30
--- /dev/null
+++ b/src/main/java/com/qtsurfer/api/sdk/auth/InMemoryTokenStore.java
@@ -0,0 +1,30 @@
+package com.qtsurfer.api.sdk.auth;
+
+import com.qtsurfer.api.client.model.AuthTokenResponse;
+
+import java.util.concurrent.atomic.AtomicReference;
+
+/**
+ * Default {@link TokenStore} — holds the most recent token in a single
+ * in-memory slot. Lost on JVM exit. Sufficient for short-lived scripts and
+ * for tests.
+ */
+public final class InMemoryTokenStore implements TokenStore {
+
+ private final AtomicReference ref = new AtomicReference<>();
+
+ @Override
+ public AuthTokenResponse load() {
+ return ref.get();
+ }
+
+ @Override
+ public void save(AuthTokenResponse token) {
+ ref.set(token);
+ }
+
+ @Override
+ public void clear() {
+ ref.set(null);
+ }
+}
diff --git a/src/main/java/com/qtsurfer/api/sdk/auth/TokenStore.java b/src/main/java/com/qtsurfer/api/sdk/auth/TokenStore.java
new file mode 100644
index 0000000..aaafd90
--- /dev/null
+++ b/src/main/java/com/qtsurfer/api/sdk/auth/TokenStore.java
@@ -0,0 +1,27 @@
+package com.qtsurfer.api.sdk.auth;
+
+import com.qtsurfer.api.client.model.AuthTokenResponse;
+
+/**
+ * Pluggable token persistence strategy for {@link AuthenticatedClient}.
+ *
+ * The SDK ships an {@link InMemoryTokenStore} as the default. Adopters
+ * implement this SAM to back tokens by an on-disk file, a secret manager,
+ * etc. The SDK calls {@link #load()} once at session start to seed any
+ * previously cached token, {@link #save(AuthTokenResponse)} after every
+ * successful {@code auth()} / refresh, and {@link #clear()} when the
+ * session is explicitly invalidated.
+ *
+ *
Implementations are expected to be thread-safe.
+ */
+public interface TokenStore {
+
+ /** Return the persisted token, or {@code null} if none. */
+ AuthTokenResponse load();
+
+ /** Persist the token returned by {@code POST /v1/auth/token}. */
+ void save(AuthTokenResponse token);
+
+ /** Drop any persisted token. */
+ void clear();
+}
diff --git a/src/main/java/com/qtsurfer/api/sdk/errors/QTSAuthError.java b/src/main/java/com/qtsurfer/api/sdk/errors/QTSAuthError.java
new file mode 100644
index 0000000..010f6c2
--- /dev/null
+++ b/src/main/java/com/qtsurfer/api/sdk/errors/QTSAuthError.java
@@ -0,0 +1,18 @@
+package com.qtsurfer.api.sdk.errors;
+
+/**
+ * Thrown when the {@code auth()} helper cannot mint a JWT — typically because
+ * no API key was supplied (neither argument nor {@code QTSURFER_APIKEY}
+ * environment variable), or the API key exchange returned 401 / a non-success
+ * status from {@code POST /v1/auth/token}.
+ */
+public class QTSAuthError extends QTSError {
+
+ public QTSAuthError(String message) {
+ super(message);
+ }
+
+ public QTSAuthError(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/test/java/com/qtsurfer/api/sdk/auth/AuthenticatedClientTest.java b/src/test/java/com/qtsurfer/api/sdk/auth/AuthenticatedClientTest.java
new file mode 100644
index 0000000..4ab9e32
--- /dev/null
+++ b/src/test/java/com/qtsurfer/api/sdk/auth/AuthenticatedClientTest.java
@@ -0,0 +1,263 @@
+package com.qtsurfer.api.sdk.auth;
+
+import com.qtsurfer.api.client.model.AuthTokenResponse;
+import com.qtsurfer.api.sdk.QTSurfer;
+import com.qtsurfer.api.sdk.errors.QTSAuthError;
+import com.qtsurfer.api.sdk.errors.QTSDownloadError;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for the apikey -> JWT auth helper. All HTTP traffic is served
+ * by a local {@link HttpServer} on a free port — no live network calls.
+ */
+class AuthenticatedClientTest {
+
+ private HttpServer server;
+ private String baseUrl;
+ private final List requests = new ArrayList<>();
+ private final List tokenResponses = new ArrayList<>();
+ private final AtomicInteger tokenCalls = new AtomicInteger();
+ private final List tickerStatuses = new ArrayList<>();
+ private final AtomicInteger tickerCalls = new AtomicInteger();
+
+ record RequestRecord(String path, String method, String authorization, String apikey) {}
+
+ @BeforeEach
+ void start() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", exchange -> {
+ requests.add(new RequestRecord(
+ exchange.getRequestURI().getPath(),
+ exchange.getRequestMethod(),
+ exchange.getRequestHeaders().getFirst("Authorization"),
+ exchange.getRequestHeaders().getFirst("X-API-Key")));
+ String path = exchange.getRequestURI().getPath();
+ byte[] body;
+ int status;
+ String ctype;
+ if (path.endsWith("/auth/token")) {
+ int idx = tokenCalls.getAndIncrement();
+ String resp = idx < tokenResponses.size()
+ ? tokenResponses.get(idx)
+ : tokenResponses.get(tokenResponses.size() - 1);
+ if (resp.startsWith("401:")) {
+ status = 401;
+ body = resp.substring(4).getBytes(StandardCharsets.UTF_8);
+ } else {
+ status = 200;
+ body = resp.getBytes(StandardCharsets.UTF_8);
+ }
+ ctype = "application/json";
+ } else if (path.contains("/tickers/")) {
+ int idx = tickerCalls.getAndIncrement();
+ status = idx < tickerStatuses.size()
+ ? tickerStatuses.get(idx)
+ : tickerStatuses.get(tickerStatuses.size() - 1);
+ body = status == 200
+ ? "LASTRA-OK".getBytes(StandardCharsets.UTF_8)
+ : ("{\"error\":\"" + status + "\"}").getBytes(StandardCharsets.UTF_8);
+ ctype = status == 200 ? "application/vnd.lastra" : "application/json";
+ } else {
+ status = 404;
+ body = new byte[0];
+ ctype = "application/json";
+ }
+ exchange.getResponseHeaders().add("Content-Type", ctype);
+ exchange.sendResponseHeaders(status, body.length == 0 ? -1 : body.length);
+ try (var os = exchange.getResponseBody()) {
+ if (body.length > 0) os.write(body);
+ }
+ });
+ server.start();
+ baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/v1";
+ }
+
+ @AfterEach
+ void stop() {
+ server.stop(0);
+ }
+
+ private static String jwt(String access, String tier) {
+ return "{\"access_token\":\"" + access
+ + "\",\"token_type\":\"Bearer\",\"expires_in\":3600,\"tier\":\""
+ + tier + "\"}";
+ }
+
+ private AuthOptions opts() {
+ return AuthOptions.builder().baseUrl(baseUrl).build();
+ }
+
+ @Test
+ void usesExplicitApikeyForTheMintCall() {
+ tokenResponses.add(jwt("jwt-explicit", "free"));
+ AuthenticatedClient session = QTSurfer.auth("ak_explicit", opts());
+ assertEquals("jwt-explicit", session.token().getAccessToken());
+ assertEquals(1, tokenCalls.get());
+ RequestRecord first = requests.get(0);
+ assertEquals("ak_explicit", first.apikey());
+ assertEquals("POST", first.method());
+ }
+
+ @Test
+ void readsApikeyFromEnvWhenNoArgPassed() {
+ // End-to-end: a null arg with no env raises QTSAuthError.
+ AuthOptions o = opts();
+ QTSAuthError ex = assertThrows(QTSAuthError.class, () -> QTSurfer.auth(null, o));
+ assertTrue(ex.getMessage().contains("QTSURFER_APIKEY"));
+ }
+
+ @Test
+ void resolveApikeyPrefersExplicitOverEnv() {
+ // env-var resolution is unit-tested via the package-private overload
+ // (the process env can't be mutated portably in a JUnit test).
+ assertEquals("ak_arg",
+ AuthenticatedClient.resolveApikey("ak_arg", "ak_env"));
+ assertEquals("ak_env",
+ AuthenticatedClient.resolveApikey(null, "ak_env"));
+ assertEquals("ak_env",
+ AuthenticatedClient.resolveApikey("", "ak_env"));
+ assertEquals("ak_env",
+ AuthenticatedClient.resolveApikey(" ", "ak_env"));
+ assertThrows(QTSAuthError.class,
+ () -> AuthenticatedClient.resolveApikey(null, null));
+ assertThrows(QTSAuthError.class,
+ () -> AuthenticatedClient.resolveApikey(null, ""));
+ }
+
+ @Test
+ void blankApikeyTreatedAsMissing() {
+ QTSAuthError ex = assertThrows(QTSAuthError.class, () -> QTSurfer.auth(" ", opts()));
+ assertTrue(ex.getMessage().contains("apikey"));
+ }
+
+ @Test
+ void mintFailureSurfacesAsQtsAuthError() {
+ tokenResponses.add("401:{\"code\":\"invalid_apikey\",\"message\":\"no\"}");
+ QTSAuthError ex = assertThrows(QTSAuthError.class,
+ () -> QTSurfer.auth("ak_bad", opts()));
+ assertTrue(ex.getMessage().contains("401"));
+ }
+
+ @Test
+ void saveCalledOnTokenStoreOnEachMint() {
+ tokenResponses.add(jwt("jwt-stored", "elite"));
+ AtomicReference stored = new AtomicReference<>();
+ AtomicInteger saves = new AtomicInteger();
+ TokenStore store = new TokenStore() {
+ @Override public AuthTokenResponse load() { return null; }
+ @Override public void save(AuthTokenResponse t) { stored.set(t); saves.incrementAndGet(); }
+ @Override public void clear() { stored.set(null); }
+ };
+ QTSurfer.auth("ak", AuthOptions.builder().baseUrl(baseUrl).store(store).build());
+ assertEquals(1, saves.get());
+ assertNotNull(stored.get());
+ assertEquals("jwt-stored", stored.get().getAccessToken());
+ }
+
+ @Test
+ void seedsFromTokenStoreWithoutMintingWhenLoadReturnsToken() {
+ AuthTokenResponse cached = new AuthTokenResponse();
+ cached.setAccessToken("jwt-cached");
+ cached.setTokenType(AuthTokenResponse.TokenTypeEnum.BEARER);
+ cached.setExpiresIn(3600);
+ TokenStore store = new TokenStore() {
+ @Override public AuthTokenResponse load() { return cached; }
+ @Override public void save(AuthTokenResponse t) {}
+ @Override public void clear() {}
+ };
+ // No tokenResponses primed; if the SDK mints, the server returns 404.
+ AuthenticatedClient session = QTSurfer.auth("ak",
+ AuthOptions.builder().baseUrl(baseUrl).store(store).build());
+ assertEquals("jwt-cached", session.token().getAccessToken());
+ assertEquals(0, tokenCalls.get());
+ }
+
+ @Test
+ void refreshOn401RetriesAndSucceeds() throws IOException {
+ tokenResponses.add(jwt("jwt-1", "free"));
+ tokenResponses.add(jwt("jwt-2", "free"));
+ tickerStatuses.add(401);
+ tickerStatuses.add(200);
+
+ AuthenticatedClient session = QTSurfer.auth("ak", opts());
+ try (InputStream in = session.tickers("binance", "BTC", "USDT", "2026-01-15T10")) {
+ byte[] all = in.readAllBytes();
+ assertEquals("LASTRA-OK", new String(all, StandardCharsets.UTF_8));
+ }
+ // 2 mints: initial + refresh.
+ assertEquals(2, tokenCalls.get());
+ // 2 ticker calls: original (401) + retry (200).
+ assertEquals(2, tickerCalls.get());
+ // Second ticker carried the refreshed JWT.
+ RequestRecord retried = requests.stream()
+ .filter(r -> r.path().contains("/tickers/"))
+ .reduce((a, b) -> b)
+ .orElseThrow();
+ assertEquals("Bearer jwt-2", retried.authorization());
+ }
+
+ @Test
+ void refreshOn401FailsWhenRetryAlso401() {
+ tokenResponses.add(jwt("jwt-1", "free"));
+ tokenResponses.add(jwt("jwt-2", "free"));
+ tickerStatuses.add(401);
+ tickerStatuses.add(401);
+
+ AuthenticatedClient session = QTSurfer.auth("ak", opts());
+ assertThrows(QTSDownloadError.class,
+ () -> session.tickers("binance", "BTC", "USDT", "2026-01-15T10"));
+ assertEquals(2, tokenCalls.get());
+ assertEquals(2, tickerCalls.get());
+ }
+
+ @Test
+ void non401ErrorsAreNotRetried() {
+ tokenResponses.add(jwt("jwt-1", "free"));
+ tickerStatuses.add(404);
+
+ AuthenticatedClient session = QTSurfer.auth("ak", opts());
+ assertThrows(QTSDownloadError.class,
+ () -> session.tickers("binance", "BTC", "USDT", "2026-01-15T10"));
+ // Only the initial mint — no refresh.
+ assertEquals(1, tokenCalls.get());
+ assertEquals(1, tickerCalls.get());
+ }
+
+ @Test
+ void clearWipesTheCachedTokenAndStore() {
+ tokenResponses.add(jwt("jwt-c", "free"));
+ AtomicReference stored = new AtomicReference<>();
+ TokenStore store = new TokenStore() {
+ @Override public AuthTokenResponse load() { return null; }
+ @Override public void save(AuthTokenResponse t) { stored.set(t); }
+ @Override public void clear() { stored.set(null); }
+ };
+ AuthenticatedClient session = QTSurfer.auth("ak",
+ AuthOptions.builder().baseUrl(baseUrl).store(store).build());
+ assertNotNull(session.token());
+ assertNotNull(stored.get());
+
+ session.clear();
+ assertNull(session.token());
+ assertNull(stored.get());
+ }
+}
diff --git a/src/test/java/com/qtsurfer/api/sdk/integration/AuthHelperIntegrationTest.java b/src/test/java/com/qtsurfer/api/sdk/integration/AuthHelperIntegrationTest.java
new file mode 100644
index 0000000..c8d6193
--- /dev/null
+++ b/src/test/java/com/qtsurfer/api/sdk/integration/AuthHelperIntegrationTest.java
@@ -0,0 +1,126 @@
+package com.qtsurfer.api.sdk.integration;
+
+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.AuthenticatedClient;
+import com.qtsurfer.api.sdk.auth.TokenStore;
+import com.qtsurfer.api.sdk.errors.QTSAuthError;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Offline integration test for the {@code auth(apikey)} helper.
+ *
+ * Drives the helper through the public {@code QTSurfer.auth(...)} entry
+ * point against a real {@link HttpServer} on a free port — exercising the
+ * full ApiClient stack (JDK {@code HttpClient}, request interceptors,
+ * Jackson decoding) without ever leaving the test JVM.
+ *
+ *
Mirrors the suffix convention of {@code BacktestIntegrationTest} so
+ * Surefire's {@code *IntegrationTest} include picks it up, but unlike the
+ * latter it does not require {@code JWT_API_TOKEN}.
+ */
+class AuthHelperIntegrationTest {
+
+ private HttpServer server;
+ private String baseUrl;
+ private final List tokenResponses = new ArrayList<>();
+ private final AtomicReference lastApikey = new AtomicReference<>();
+
+ @BeforeEach
+ void start() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", exchange -> {
+ String path = exchange.getRequestURI().getPath();
+ byte[] body;
+ int status;
+ if (path.endsWith("/auth/token")) {
+ lastApikey.set(exchange.getRequestHeaders().getFirst("X-API-Key"));
+ if (tokenResponses.isEmpty()) {
+ status = 500;
+ body = "{}".getBytes(StandardCharsets.UTF_8);
+ } else {
+ String resp = tokenResponses.remove(0);
+ if (resp.startsWith("401:")) {
+ status = 401;
+ body = resp.substring(4).getBytes(StandardCharsets.UTF_8);
+ } else {
+ status = 200;
+ body = resp.getBytes(StandardCharsets.UTF_8);
+ }
+ }
+ } else {
+ status = 404;
+ body = new byte[0];
+ }
+ exchange.getResponseHeaders().add("Content-Type", "application/json");
+ exchange.sendResponseHeaders(status, body.length == 0 ? -1 : body.length);
+ try (var os = exchange.getResponseBody()) {
+ if (body.length > 0) os.write(body);
+ }
+ });
+ server.start();
+ baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/v1";
+ }
+
+ @AfterEach
+ void stop() {
+ server.stop(0);
+ }
+
+ @Test
+ void endToEnd_mintAndExposeToken() {
+ tokenResponses.add(
+ "{\"access_token\":\"jwt-int\",\"token_type\":\"Bearer\","
+ + "\"expires_in\":3600,\"tier\":\"pro\"}");
+
+ AuthenticatedClient session = QTSurfer.auth("ak_int",
+ AuthOptions.builder().baseUrl(baseUrl).build());
+
+ AuthTokenResponse token = session.token();
+ assertNotNull(token);
+ assertEquals("jwt-int", token.getAccessToken());
+ assertEquals(AuthTokenResponse.TierEnum.PRO, token.getTier());
+ assertEquals("ak_int", lastApikey.get());
+ }
+
+ @Test
+ void endToEnd_tokenStorePluginReceivesSavedToken() {
+ tokenResponses.add(
+ "{\"access_token\":\"jwt-store\",\"token_type\":\"Bearer\","
+ + "\"expires_in\":3600,\"tier\":\"free\"}");
+
+ List saved = new ArrayList<>();
+ TokenStore store = new TokenStore() {
+ @Override public AuthTokenResponse load() { return null; }
+ @Override public void save(AuthTokenResponse t) { saved.add(t); }
+ @Override public void clear() { saved.clear(); }
+ };
+
+ QTSurfer.auth("ak", AuthOptions.builder().baseUrl(baseUrl).store(store).build());
+ assertEquals(1, saved.size());
+ assertEquals("jwt-store", saved.get(0).getAccessToken());
+ }
+
+ @Test
+ void endToEnd_mint401SurfacesAsQtsAuthError() {
+ tokenResponses.add("401:{\"code\":\"invalid_apikey\",\"message\":\"x\"}");
+ assertThrows(QTSAuthError.class,
+ () -> QTSurfer.auth("ak_bad",
+ AuthOptions.builder().baseUrl(baseUrl).build()));
+ }
+}