Skip to content

Commit aeda94d

Browse files
committed
feat: hourly tickers/klines downloads as Lastra/Parquet streams (0.2.0)
Two new SDK methods that wrap the binary endpoints introduced in net.qtsurfer:api-client v0.1.2: QTSurfer#tickers(exchangeId, base, quote, hour[, format]) -> InputStream QTSurfer#klines (exchangeId, base, quote, hour[, format]) -> InputStream The default wire format is Lastra (application/vnd.lastra) which streams the raw QTSurfer columnar bytes; pass DownloadFormat.PARQUET for on-the-fly Parquet conversion server-side. The returned InputStream is caller-owned (use try-with-resources / Files.copy / a Lastra reader). HTTP errors surface as QTSDownloadError, a new subclass of QTSError. Both methods are unit-tested against an in-process com.sun.net.httpserver (URL building, format query param, bearer auth header, error mapping to QTSDownloadError). Bump api-client dependency to v0.1.2 (ExchangeBinaryDownloads). This release also formalizes the v0.2 domain-objects work that was already on main but never tagged: Strategy/Backtest handles plus the removal of internal staging URLs from the integration test default. See CHANGELOG.md for the consolidated entry.
1 parent 51e3e2c commit aeda94d

7 files changed

Lines changed: 250 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66

77
## [Unreleased]
88

9+
## [0.2.0] — 2026-05-01
10+
11+
### Added
12+
13+
- **Domain objects (`Strategy`, `Backtest`):**
14+
- `QTSurfer#compile(...)` returns a reusable `Strategy` handle that can launch multiple backtests.
15+
- `Strategy#backtest(...)` returns a `Backtest` handle exposing `id()`, `state()`, `progress()` (a `Flow.Publisher<BacktestProgress>`), `await()`, and `cancel()`.
16+
- `QTSurfer#backtest(request, options)` shortcut now composes `compile → backtest → await` over the new objects.
17+
- **Hourly tickers/klines downloads:**
18+
- `QTSurfer#tickers(exchangeId, base, quote, hour[, format])` and `QTSurfer#klines(...)` — stream one hour of raw tickers or klines as `InputStream`.
19+
- `DownloadFormat` enum (`LASTRA` default, `PARQUET` for on-the-fly conversion).
20+
- `QTSDownloadError` (subclass of `QTSError`) — surfaced when the download fails (HTTP 4xx/5xx, transport error).
21+
22+
### Changed
23+
24+
- `api-client` dependency bumped to `v0.1.2` (adds `ExchangeBinaryDownloads`).
25+
- Internal `Backtest` workflow class renamed to `BacktestWorkflow` to free the public `Backtest` name for the new domain handle.
26+
27+
### Removed
28+
29+
- Hardcoded staging URL from the integration test default; `QTSURFER_API_URL` is now required alongside `JWT_API_TOKEN` (the test skips when either is absent).
30+
- Javadoc and README examples use the public domain (`api.qtsurfer.com`) instead of internal/staging URLs.
31+
932
## [0.1.0] — 2026-04-15
1033

1134
### Added

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,28 @@ Progress is emitted:
106106
- On every stage transition (`percent == null`).
107107
- After each poll where the backend reports `size > 0` (`percent` in 0–100).
108108

109+
## Hourly tickers/klines downloads
110+
111+
Stream one hour of raw ticker or kline data for an instrument. The default wire format is
112+
[Lastra](https://github.com/QTSurfer/lastra-java) (`application/vnd.lastra`); pass
113+
`DownloadFormat.PARQUET` for on-the-fly Parquet conversion.
114+
115+
```java
116+
import net.qtsurfer.api.sdk.DownloadFormat;
117+
118+
// Lastra (default), streamed straight to disk
119+
try (var in = qts.tickers("binance", "BTC", "USDT", "2026-01-15T10")) {
120+
Files.copy(in, Path.of("BTC_USDT_2026-01-15_h10.lastra"));
121+
}
122+
123+
// Parquet
124+
try (var in = qts.klines("binance", "BTC", "USDT", "2026-01-15T10", DownloadFormat.PARQUET)) {
125+
// feed into Apache Parquet, DuckDB, etc.
126+
}
127+
```
128+
129+
The caller closes the stream. HTTP errors surface as `QTSDownloadError` (subclass of `QTSError`).
130+
109131
## Error hierarchy
110132

111133
All SDK errors extend `QTSError` (a `RuntimeException`) and surface as the cause of the `CompletionException` wrapping them when the future fails.
@@ -119,6 +141,7 @@ try {
119141
case QTSStrategyCompileError x -> log.error("Compile failed: {}", x.getMessage());
120142
case QTSPreparationError x -> log.error("Data prep failed: {}", x.getMessage());
121143
case QTSExecutionError x -> log.error("Execution failed: {}", x.getMessage());
144+
case QTSDownloadError x -> log.error("Download failed: {}", x.getMessage());
122145
case QTSTimeoutError x -> log.error("Stage timed out: {}", x.getMessage());
123146
case QTSCanceledError x -> log.error("Canceled");
124147
default -> throw e;

pom.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>net.qtsurfer</groupId>
88
<artifactId>sdk</artifactId>
9-
<version>0.1.0</version>
9+
<version>0.2.0</version>
1010
<packaging>jar</packaging>
1111

1212
<name>QTSurfer SDK</name>
@@ -48,7 +48,7 @@
4848
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
4949
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
5050

51-
<api.client.version>v0.1.1</api.client.version>
51+
<api.client.version>v0.1.2</api.client.version>
5252
<failsafe.version>3.3.2</failsafe.version>
5353
<slf4j.version>2.0.16</slf4j.version>
5454
<jackson.version>2.18.2</jackson.version>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package net.qtsurfer.api.sdk;
2+
3+
import net.qtsurfer.api.client.binary.ExchangeBinaryDownloads;
4+
5+
/**
6+
* Wire format for hourly tickers/klines downloads.
7+
*
8+
* <ul>
9+
* <li>{@link #LASTRA} — native QTSurfer columnar format ({@code application/vnd.lastra}).
10+
* Cheaper to serve and to consume. Use a Lastra reader (e.g. {@code lastra-java}).</li>
11+
* <li>{@link #PARQUET} — Apache Parquet, converted on-the-fly server-side
12+
* ({@code application/vnd.apache.parquet}). Use when the client can't read Lastra.</li>
13+
* </ul>
14+
*/
15+
public enum DownloadFormat {
16+
LASTRA(ExchangeBinaryDownloads.Format.LASTRA),
17+
PARQUET(ExchangeBinaryDownloads.Format.PARQUET);
18+
19+
private final ExchangeBinaryDownloads.Format wire;
20+
21+
DownloadFormat(ExchangeBinaryDownloads.Format wire) {
22+
this.wire = wire;
23+
}
24+
25+
ExchangeBinaryDownloads.Format wire() {
26+
return wire;
27+
}
28+
}

src/main/java/net/qtsurfer/api/sdk/QTSurfer.java

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package net.qtsurfer.api.sdk;
22

33
import net.qtsurfer.api.client.api.BacktestingApi;
4+
import net.qtsurfer.api.client.binary.ExchangeBinaryDownloads;
45
import net.qtsurfer.api.client.invoker.ApiClient;
6+
import net.qtsurfer.api.client.invoker.ApiException;
57
import net.qtsurfer.api.client.model.ResultMap;
8+
import net.qtsurfer.api.sdk.errors.QTSDownloadError;
69
import net.qtsurfer.api.sdk.internal.HttpStrategyCompileClient;
710
import net.qtsurfer.api.sdk.workflows.BacktestWorkflow;
811

12+
import java.io.InputStream;
913
import java.util.Objects;
1014
import java.util.concurrent.CompletableFuture;
1115
import java.util.concurrent.ExecutorService;
@@ -35,10 +39,12 @@ public final class QTSurfer {
3539

3640
private final QTSurferOptions options;
3741
private final BacktestWorkflow backtestWorkflow;
42+
private final ExchangeBinaryDownloads downloads;
3843

39-
private QTSurfer(QTSurferOptions options, BacktestWorkflow backtestWorkflow) {
44+
private QTSurfer(QTSurferOptions options, BacktestWorkflow backtestWorkflow, ExchangeBinaryDownloads downloads) {
4045
this.options = options;
4146
this.backtestWorkflow = backtestWorkflow;
47+
this.downloads = downloads;
4248
}
4349

4450
public QTSurferOptions options() { return options; }
@@ -78,6 +84,58 @@ public CompletableFuture<ResultMap> backtest(BacktestRequest request, BacktestOp
7884
return backtestWorkflow.runFull(request, options);
7985
}
8086

87+
/**
88+
* Download one hour of raw tickers for an instrument as a streaming
89+
* {@link InputStream}. Defaults to {@link DownloadFormat#LASTRA}; pass
90+
* {@link DownloadFormat#PARQUET} for on-the-fly Parquet conversion.
91+
*
92+
* <p>The caller is responsible for closing the stream — typically via
93+
* try-with-resources, piping to {@code Files.copy(...)}, or feeding it
94+
* into a Lastra/Parquet reader.
95+
*
96+
* @throws QTSDownloadError on HTTP 4xx/5xx or transport failure
97+
*/
98+
public InputStream tickers(String exchangeId, String base, String quote, String hour) {
99+
return tickers(exchangeId, base, quote, hour, DownloadFormat.LASTRA);
100+
}
101+
102+
public InputStream tickers(String exchangeId, String base, String quote, String hour, DownloadFormat format) {
103+
Objects.requireNonNull(format, "format");
104+
try {
105+
return downloads.getTickersHour(exchangeId, base, quote, hour, format.wire());
106+
} catch (ApiException e) {
107+
throw new QTSDownloadError(
108+
"tickers download failed: " + describe(e), e);
109+
}
110+
}
111+
112+
/**
113+
* Download one hour of klines for an instrument as a streaming
114+
* {@link InputStream}. See {@link #tickers} for semantics.
115+
*
116+
* @throws QTSDownloadError on HTTP 4xx/5xx or transport failure
117+
*/
118+
public InputStream klines(String exchangeId, String base, String quote, String hour) {
119+
return klines(exchangeId, base, quote, hour, DownloadFormat.LASTRA);
120+
}
121+
122+
public InputStream klines(String exchangeId, String base, String quote, String hour, DownloadFormat format) {
123+
Objects.requireNonNull(format, "format");
124+
try {
125+
return downloads.getKlinesHour(exchangeId, base, quote, hour, format.wire());
126+
} catch (ApiException e) {
127+
throw new QTSDownloadError(
128+
"klines download failed: " + describe(e), e);
129+
}
130+
}
131+
132+
private static String describe(ApiException e) {
133+
if (e.getResponseBody() != null && !e.getResponseBody().isBlank()) {
134+
return "HTTP " + e.getCode() + " — " + e.getResponseBody();
135+
}
136+
return "HTTP " + e.getCode();
137+
}
138+
81139
public static Builder builder() { return new Builder(); }
82140

83141
public static final class Builder {
@@ -99,8 +157,9 @@ public QTSurfer build() {
99157
}
100158
BacktestingApi backtestingApi = new BacktestingApi(apiClient);
101159
ExecutorService exec = opts.executor() != null ? opts.executor() : ForkJoinPool.commonPool();
102-
return new QTSurfer(opts, new BacktestWorkflow(
103-
new HttpStrategyCompileClient(apiClient), backtestingApi, exec));
160+
BacktestWorkflow workflow = new BacktestWorkflow(
161+
new HttpStrategyCompileClient(apiClient), backtestingApi, exec);
162+
return new QTSurfer(opts, workflow, new ExchangeBinaryDownloads(apiClient));
104163
}
105164
}
106165
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package net.qtsurfer.api.sdk.errors;
2+
3+
/**
4+
* Raised when an hourly tickers/klines download fails — wraps the underlying
5+
* {@link net.qtsurfer.api.client.invoker.ApiException} (HTTP 4xx/5xx, transport error).
6+
*/
7+
public class QTSDownloadError extends QTSError {
8+
9+
public QTSDownloadError(String message) {
10+
super(message);
11+
}
12+
13+
public QTSDownloadError(String message, Throwable cause) {
14+
super(message, cause);
15+
}
16+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package net.qtsurfer.api.sdk;
2+
3+
import com.sun.net.httpserver.HttpExchange;
4+
import com.sun.net.httpserver.HttpServer;
5+
import net.qtsurfer.api.sdk.errors.QTSDownloadError;
6+
import org.junit.jupiter.api.AfterEach;
7+
import org.junit.jupiter.api.BeforeEach;
8+
import org.junit.jupiter.api.Test;
9+
10+
import java.io.IOException;
11+
import java.io.InputStream;
12+
import java.net.InetSocketAddress;
13+
import java.nio.charset.StandardCharsets;
14+
import java.util.ArrayList;
15+
import java.util.List;
16+
import java.util.concurrent.atomic.AtomicReference;
17+
18+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
19+
import static org.junit.jupiter.api.Assertions.assertEquals;
20+
import static org.junit.jupiter.api.Assertions.assertThrows;
21+
import static org.junit.jupiter.api.Assertions.assertTrue;
22+
23+
class DownloadsTest {
24+
25+
private HttpServer server;
26+
private QTSurfer qts;
27+
private final List<HttpExchange> exchanges = new ArrayList<>();
28+
private final AtomicReference<byte[]> responseBody = new AtomicReference<>(new byte[0]);
29+
private final AtomicReference<Integer> responseStatus = new AtomicReference<>(200);
30+
31+
@BeforeEach
32+
void start() throws IOException {
33+
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
34+
server.createContext("/", exchange -> {
35+
exchanges.add(exchange);
36+
byte[] body = responseBody.get();
37+
int status = responseStatus.get();
38+
exchange.getResponseHeaders().add("Content-Type", "application/vnd.lastra");
39+
exchange.sendResponseHeaders(status, body.length == 0 ? -1 : body.length);
40+
try (var os = exchange.getResponseBody()) {
41+
if (body.length > 0) os.write(body);
42+
}
43+
});
44+
server.start();
45+
qts = QTSurfer.builder()
46+
.baseUrl("http://127.0.0.1:" + server.getAddress().getPort())
47+
.token("test-token")
48+
.build();
49+
}
50+
51+
@AfterEach
52+
void stop() {
53+
server.stop(0);
54+
}
55+
56+
@Test
57+
void tickersDefaultsToLastra() throws IOException {
58+
byte[] payload = "LASTRA-PAYLOAD".getBytes(StandardCharsets.UTF_8);
59+
responseBody.set(payload);
60+
61+
try (InputStream in = qts.tickers("binance", "BTC", "USDT", "2026-01-15T10")) {
62+
assertArrayEquals(payload, in.readAllBytes());
63+
}
64+
65+
HttpExchange recorded = exchanges.get(0);
66+
assertEquals("/exchange/binance/tickers/BTC/USDT", recorded.getRequestURI().getPath());
67+
assertEquals("hour=2026-01-15T10&format=lastra", recorded.getRequestURI().getRawQuery());
68+
assertEquals("Bearer test-token", recorded.getRequestHeaders().getFirst("Authorization"));
69+
}
70+
71+
@Test
72+
void klinesEmitsParquetFormatWhenRequested() throws IOException {
73+
responseBody.set("ok".getBytes(StandardCharsets.UTF_8));
74+
75+
try (InputStream in = qts.klines("binance", "BTC", "USDT", "2026-01-15T10", DownloadFormat.PARQUET)) {
76+
in.readAllBytes();
77+
}
78+
79+
HttpExchange recorded = exchanges.get(0);
80+
assertEquals("/exchange/binance/klines/BTC/USDT", recorded.getRequestURI().getPath());
81+
assertEquals("hour=2026-01-15T10&format=parquet", recorded.getRequestURI().getRawQuery());
82+
}
83+
84+
@Test
85+
void mapsApiExceptionToQtsDownloadError() {
86+
responseStatus.set(404);
87+
responseBody.set("{\"code\":\"NOT_FOUND\",\"message\":\"hour not backfilled\"}"
88+
.getBytes(StandardCharsets.UTF_8));
89+
90+
QTSDownloadError ex = assertThrows(
91+
QTSDownloadError.class,
92+
() -> qts.tickers("binance", "BTC", "USDT", "2026-01-15T10"));
93+
assertTrue(ex.getMessage().contains("HTTP 404"));
94+
assertTrue(ex.getMessage().contains("NOT_FOUND"));
95+
}
96+
}

0 commit comments

Comments
 (0)