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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 79 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,18 +39,95 @@ Where `com.qtsurfer:api-client-java` gives you one method per endpoint, this pac
<dependency>
<groupId>com.qtsurfer</groupId>
<artifactId>sdk-java</artifactId>
<version>0.4.1</version>
<version>x.x.x</version>
</dependency>
```

The transitive `com.qtsurfer:api-client-java` and `dev.failsafe:failsafe` come along automatically.

### 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;
Expand Down
6 changes: 3 additions & 3 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<modelVersion>4.0.0</modelVersion>

<groupId>com.qtsurfer</groupId>
<artifactId>sdk</artifactId>
<version>0.4.1</version>
<artifactId>sdk-java</artifactId>
<version>0.5.0</version>
<packaging>jar</packaging>

<name>QTSurfer SDK</name>
Expand Down Expand Up @@ -48,7 +48,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

<api.client.version>0.2.0</api.client.version>
<api.client.version>0.3.1</api.client.version>
<failsafe.version>3.3.2</failsafe.version>
<slf4j.version>2.0.16</slf4j.version>
<jackson.version>2.18.2</jackson.version>
Expand Down
7 changes: 6 additions & 1 deletion src/main/java/com/qtsurfer/api/sdk/DownloadFormat.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
28 changes: 28 additions & 0 deletions src/main/java/com/qtsurfer/api/sdk/QTSurfer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
* <p>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 {
Expand Down
53 changes: 53 additions & 0 deletions src/main/java/com/qtsurfer/api/sdk/auth/AuthOptions.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading
Loading