Skip to content

Commit b477fff

Browse files
committed
feat: add venue catalogues and binary downloads
1 parent 7a5bf0b commit b477fff

59 files changed

Lines changed: 2087 additions & 168 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/publish.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ jobs:
7171
GH_TOKEN: ${{ github.token }}
7272
run: |
7373
VERSION="${GITHUB_REF_NAME#v}"
74-
awk "/^## \[${VERSION}\]/{found=1; print; next} found && /^## \[/{exit} found{print}" \
74+
awk "/^## \[${VERSION}\]/{found=1; next} found && /^## \[/{exit} found{print}" \
7575
CHANGELOG.md > /tmp/release-notes.md
7676
if [ ! -s /tmp/release-notes.md ]; then
7777
echo "No CHANGELOG section for ${VERSION}; refusing to publish an empty release." >&2

‎CHANGELOG.md‎

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

77
## [Unreleased]
88

9+
## [0.128.36] — 2026-10-17
10+
11+
### Added ✨
12+
13+
- Discover CEX instruments and DEX pools through both instrument routes, with typed venue,
14+
segment and live-segment metadata. `InstrumentCatalogueResponse` names their response union.
15+
- Import centralized-exchange ticker or kline history with the `cex` source block and fifteen
16+
cadence labels; retain typed `ResponseError.reason` and `next_` recovery instructions.
17+
- Public `api.exchange.download_tickers` and `download_klines` adapters return successful
18+
binary bytes for sync and async calls, including strict-status clients. Detailed calls preserve
19+
provisional-hour metadata, timestamp units, and retry delays.
20+
21+
### Changed 🔄
22+
23+
- Token scopes are a `KeyScopes` resource-to-actions map, also required on account responses.
24+
The API reports grants but does not yet enforce them.
25+
- Live sources use `cex` / `dex` enums. DEX runs require one pool address and `kline` data;
26+
discover valid exchange / live-segment combinations before starting a run.
27+
- Exchange metadata and instrument `symbol` are required. Unknown market values are absent
28+
(`UNSET`), never null. Instrument segment requests accept discovered protocol strings.
29+
- Hourly downloads cover seven days. Lastra `ts` is microseconds with MIME type
30+
`application/vnd.qtsurfer.lastra`; Parquet remains milliseconds. Handle provisional `200`,
31+
`404 NOT_YET_AVAILABLE` / `NO_DATA`, `400 OUTSIDE_WINDOW` and retryable `503`.
32+
33+
### Fixed 🐛
34+
35+
- Public download adapters encode quote currencies without colliding with the URL encoder.
36+
Use the public `api.exchange` imports rather than private generated download modules.
37+
938
## [0.128.25] — 2026-10-09
1039

1140
### Added ✨

‎README.md‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ for ex in exchanges or []:
5555
print(ex.id, ex.name)
5656

5757
instruments = list_instruments.sync(client=client, exchange_id="binance")
58-
print(f"{len(instruments.data)} instruments on binance ({instruments.meta.segment.value})")
58+
print(f"{len(instruments.data)} instruments on binance ({instruments.meta.segment})")
5959
```
6060

6161
### API key → JWT
@@ -82,6 +82,18 @@ token_response = authenticate.sync(client=apikey_client)
8282
jwt = token_response.access_token # use this in subsequent calls
8383
```
8484

85+
`token_response.scopes`, when present, is a `KeyScopes` map from resource to actions,
86+
such as `{"backtests": ["read", "write", "execute"]}`, not an array. `get_account`
87+
returns the same required map. The API reports grants but does not yet enforce them.
88+
89+
Exchange discovery returns required `venue_type`, `segments`, and `live_segments`.
90+
Both instrument routes return `InstrumentCatalogueResponse`: either `InstrumentListResponse`
91+
(`meta.venue_type == "cex"`) or `PoolListResponse` (`"dex"`). Pool ids are live-run
92+
addresses; symbols name signals. Missing market values are `UNSET`, never null.
93+
Live sources use `LiveSourceRequestVenueType.CEX` / `.DEX`; DEX needs exactly one
94+
pool address, `kline`, and a discovered live protocol. CEX imports use
95+
`DatasetImportRequestType.CEX` with a `DatasetImportCexRequest` block.
96+
8597
For production use, prefer the [`qtsurfer-sdk`](https://github.com/QTSurfer/sdk-python)
8698
`auth(apikey)` helper — it handles token refresh, env-var pickup
8799
(`QTSURFER_APIKEY`), and pluggable token storage so callers don't reinvent any
@@ -240,7 +252,7 @@ responses also expose optional `reason` text when a run stops or fails.
240252

241253
> Exact module/function names are produced from `operationId` in the OpenAPI spec. Run `scripts/regenerate.sh` to refresh and check `src/qtsurfer/api/client/_generated/api/` for the authoritative listing.
242254
243-
All generated model types (`Exchange`, `InstrumentDetail`, `InstrumentCoverage`, `CoverageWindow`, `JobState`, `PrepareJobState`, `StrategyState`, `StrategyLinks`, `BacktestJobResult`, `ResultMap`, `ResponseError`, `Dataset`, `DatasetWithLinks`, `DatasetCreated`, `DatasetVersion`, `DatasetUploadState`, …) live under `qtsurfer.api.client.models`. `list_instruments`/`list_segment_instruments` return an `InstrumentListResponse` (HAL envelope: `data` + `meta` + `_links`), not a bare list — each `InstrumentDetail.coverage` carries per-data-type `CoverageWindow`s instead of flat `dataFrom`/`dataTo`. A single-instrument `get_prepare_status` returns a `PrepareJobState` — always terminal (`status: Completed`), with a `coverage_ratio` and a per-hour `hours_without_data` breakdown to act on instead of polling. Against a dataset-backed prepare (`exchangeId: user`), `PrepareJobState` reports coverage on the dataset's own cadence grid instead — `cadence`/`gaps`/`largest_gap_steps` — with `total_hours`/`hours_with_data`/`hours_without_data` absent in that case. `get_strategy` returns a `StrategyState`, whose `validation` field (`not_validated` / `pending` / `passed` / `failed`) reports the outcome of the most recent `validate_strategy` check rather than a compile job status.
255+
All generated model types (`Exchange`, `InstrumentDetail`, `InstrumentCoverage`, `CoverageWindow`, `JobState`, `PrepareJobState`, `StrategyState`, `StrategyLinks`, `BacktestJobResult`, `ResultMap`, `ResponseError`, `Dataset`, `DatasetWithLinks`, `DatasetCreated`, `DatasetVersion`, `DatasetUploadState`, …) live under `qtsurfer.api.client.models`. `list_instruments`/`list_segment_instruments` return `InstrumentCatalogueResponse`, either `InstrumentListResponse` for CEX or `PoolListResponse` for DEX (HAL envelope: `data` + `meta` + `_links`) — each `InstrumentDetail.coverage` carries per-data-type `CoverageWindow`s instead of flat `dataFrom`/`dataTo`. A single-instrument `get_prepare_status` returns a `PrepareJobState` — always terminal (`status: Completed`), with a `coverage_ratio` and a per-hour `hours_without_data` breakdown to act on instead of polling. Against a dataset-backed prepare (`exchangeId: user`), `PrepareJobState` reports coverage on the dataset's own cadence grid instead — `cadence`/`gaps`/`largest_gap_steps` — with `total_hours`/`hours_with_data`/`hours_without_data` absent in that case. `get_strategy` returns a `StrategyState`, whose `validation` field (`not_validated` / `pending` / `passed` / `failed`) reports the outcome of the most recent `validate_strategy` check rather than a compile job status.
244256

245257
A full `StrategyState` (from `get_strategy`, and from `validate_strategy`'s already-validated `200`) carries an optional `field_links` (`_links` on the wire) — a `StrategyLinks` with a `code: HalLink` pointing at `get_strategy_code`. `validate_strategy`'s `202` omits it, since a check that just started has nothing to link to yet. `list_strategies` returns every strategy you've registered and not deleted, most recently compiled first, but deliberately without each one's `validation` state — check that per strategy with `get_strategy`. `delete_strategy` removes a strategy from both `get_strategy` and `list_strategies`; it doesn't touch backtests already run against it, and re-submitting the same source afterwards registers a new strategy under a new id. `get_strategy_code`'s `404` covers two indistinguishable cases: an id never registered by you, or one that resolves only through a shared/marketplace reference with no source of its own.
246258

@@ -259,10 +271,14 @@ server-applied size guard, so treat it as authoritative over requested defaults.
259271
260272
### Binary downloads (`/exchange/{ex}/tickers|klines/{base}/{quote}`)
261273

262-
These endpoints return raw [Lastra](https://github.com/QTSurfer/lastra-java) bytes (default) or Parquet (`format=parquet`). The generated `sync()` helpers parse the response body as JSON and will raise on binary payloads; use `sync_detailed()` and read `response.content` directly:
274+
These endpoints return raw [Lastra](https://github.com/QTSurfer/lastra-java) bytes (default) or
275+
Parquet (`format=parquet`). Import the public `api.exchange` adapters: generated binary
276+
success responses are omitted by the generator. Public `sync()` / `asyncio()` return bytes
277+
or `ResponseError`; detailed variants retain headers, status, parsed value, and content.
263278

264279
```python
265280
from qtsurfer.api.client import AuthenticatedClient
281+
from datetime import UTC, datetime, timedelta
266282
from qtsurfer.api.client.api.exchange import download_tickers
267283

268284
client = AuthenticatedClient(base_url="https://api.qtsurfer.net/v1", token=token)
@@ -272,19 +288,31 @@ response = download_tickers.sync_detailed(
272288
exchange_id="binance",
273289
base="BTC",
274290
quote="USDT",
275-
hour="2026-01-15T10",
291+
hour=(datetime.now(UTC) - timedelta(hours=2)).strftime("%Y-%m-%dT%H"),
276292
)
277-
with open("BTC_USDT_2026-01-15_h10.lastra", "wb") as f:
278-
f.write(response.content)
293+
if response.status_code == 200:
294+
with open("BTC_USDT.lastra", "wb") as f:
295+
f.write(response.content)
296+
else:
297+
print(response.parsed.reason, response.headers.get("Retry-After"))
279298
```
280299

300+
Hourly downloads cover seven days. Lastra MIME is `application/vnd.qtsurfer.lastra`,
301+
and `ts` uses microseconds (`X-Ts-Unit: micros` and column metadata `ts_unit: micros`);
302+
Parquet timestamps remain milliseconds. Recent hours may be provisional (`X-Data-Status`
303+
and ISO-8601 `X-Data-Through`) or unavailable (`404 NOT_YET_AVAILABLE` with `Retry-After`).
304+
Do not archive provisional data as a complete hour. `404 NO_DATA` is a stored empty hour;
305+
`503 TEMPORARILY_UNAVAILABLE` is retryable after `Retry-After`. `400 OUTSIDE_WINDOW`
306+
includes `ResponseError.next_`, a CEX import request for the hour; add a dataset name.
307+
Unknown reasons should be handled like absent reasons, using status and message.
308+
281309
For very large segments, drop down to the underlying `httpx.Client` (`client.get_httpx_client()`) and stream:
282310

283311
```python
284312
with client.get_httpx_client().stream(
285313
"GET",
286314
"/exchange/binance/klines/BTC/USDT",
287-
params={"hour": "2026-01-15T10", "format": "parquet"},
315+
params={"hour": "2026-10-15T10", "format": "parquet"},
288316
) as r:
289317
r.raise_for_status()
290318
with open("out.parquet", "wb") as f:

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "qtsurfer-api-client"
3-
version = "0.128.25"
3+
version = "0.128.36"
44
description = "Auto-generated Python client for the QTSurfer API."
55
readme = "README.md"
66
license = "Apache-2.0"

‎src/qtsurfer/api/client/_generated/api/dataset/import_dataset.py‎

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,18 @@ def sync_detailed(
8383
as a dataset version indistinguishable from an uploaded one once it's ready. Poll
8484
`GET /datasets/{datasetId}/imports/{importId}` for progress.
8585
86-
`type` selects the source. `dex` — history over a pool/pair's own on-chain market — is the
87-
only value today; other source types join this same endpoint later.
86+
`type` selects the kind of venue the history comes from, with the same two values as the
87+
`venueType` of `GET /exchanges`: `dex` — history over a pool/pair's own on-chain market —
88+
and `cex` — the history of a centralized exchange's market, kept by the platform. Each
89+
has its own block (`dex`, `cex`). The exchange id of a `cex` import has to be a centralized
90+
exchange of `GET /exchanges`; a decentralized one (`robinhood`, say) is `400`, and is imported
91+
with `type: dex`.
92+
93+
A `cex` import covers one instrument over a range: the API reads that range of the
94+
exchange's history, builds the bars of the requested `cadence` from the finest data it has
95+
for it, and stores the result as a dataset version, exactly as for an upload. `cex.dataType`
96+
picks tickers or klines and is also the resulting dataset's `type`. The range is capped by
97+
your tier like any import (`maxImportRangeHours` of `GET /account`).
8898
8999
A `dex` import has two data shapes, chosen by the top-level `cadence`:
90100
@@ -100,7 +110,8 @@ def sync_detailed(
100110
Args:
101111
body (DatasetImportRequest): `POST /datasets/imports`'s request body. A common block plus
102112
one type-specific block,
103-
selected by `type` — `dex` is the only value today.
113+
selected by `type`: `dex` (block `dex`) or `cex` (block `cex`), the two `venueType`s of
114+
`GET /exchanges`.
104115
105116
Raises:
106117
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
@@ -134,8 +145,18 @@ def sync(
134145
as a dataset version indistinguishable from an uploaded one once it's ready. Poll
135146
`GET /datasets/{datasetId}/imports/{importId}` for progress.
136147
137-
`type` selects the source. `dex` — history over a pool/pair's own on-chain market — is the
138-
only value today; other source types join this same endpoint later.
148+
`type` selects the kind of venue the history comes from, with the same two values as the
149+
`venueType` of `GET /exchanges`: `dex` — history over a pool/pair's own on-chain market —
150+
and `cex` — the history of a centralized exchange's market, kept by the platform. Each
151+
has its own block (`dex`, `cex`). The exchange id of a `cex` import has to be a centralized
152+
exchange of `GET /exchanges`; a decentralized one (`robinhood`, say) is `400`, and is imported
153+
with `type: dex`.
154+
155+
A `cex` import covers one instrument over a range: the API reads that range of the
156+
exchange's history, builds the bars of the requested `cadence` from the finest data it has
157+
for it, and stores the result as a dataset version, exactly as for an upload. `cex.dataType`
158+
picks tickers or klines and is also the resulting dataset's `type`. The range is capped by
159+
your tier like any import (`maxImportRangeHours` of `GET /account`).
139160
140161
A `dex` import has two data shapes, chosen by the top-level `cadence`:
141162
@@ -151,7 +172,8 @@ def sync(
151172
Args:
152173
body (DatasetImportRequest): `POST /datasets/imports`'s request body. A common block plus
153174
one type-specific block,
154-
selected by `type` — `dex` is the only value today.
175+
selected by `type`: `dex` (block `dex`) or `cex` (block `cex`), the two `venueType`s of
176+
`GET /exchanges`.
155177
156178
Raises:
157179
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
@@ -180,8 +202,18 @@ async def asyncio_detailed(
180202
as a dataset version indistinguishable from an uploaded one once it's ready. Poll
181203
`GET /datasets/{datasetId}/imports/{importId}` for progress.
182204
183-
`type` selects the source. `dex` — history over a pool/pair's own on-chain market — is the
184-
only value today; other source types join this same endpoint later.
205+
`type` selects the kind of venue the history comes from, with the same two values as the
206+
`venueType` of `GET /exchanges`: `dex` — history over a pool/pair's own on-chain market —
207+
and `cex` — the history of a centralized exchange's market, kept by the platform. Each
208+
has its own block (`dex`, `cex`). The exchange id of a `cex` import has to be a centralized
209+
exchange of `GET /exchanges`; a decentralized one (`robinhood`, say) is `400`, and is imported
210+
with `type: dex`.
211+
212+
A `cex` import covers one instrument over a range: the API reads that range of the
213+
exchange's history, builds the bars of the requested `cadence` from the finest data it has
214+
for it, and stores the result as a dataset version, exactly as for an upload. `cex.dataType`
215+
picks tickers or klines and is also the resulting dataset's `type`. The range is capped by
216+
your tier like any import (`maxImportRangeHours` of `GET /account`).
185217
186218
A `dex` import has two data shapes, chosen by the top-level `cadence`:
187219
@@ -197,7 +229,8 @@ async def asyncio_detailed(
197229
Args:
198230
body (DatasetImportRequest): `POST /datasets/imports`'s request body. A common block plus
199231
one type-specific block,
200-
selected by `type` — `dex` is the only value today.
232+
selected by `type`: `dex` (block `dex`) or `cex` (block `cex`), the two `venueType`s of
233+
`GET /exchanges`.
201234
202235
Raises:
203236
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
@@ -229,8 +262,18 @@ async def asyncio(
229262
as a dataset version indistinguishable from an uploaded one once it's ready. Poll
230263
`GET /datasets/{datasetId}/imports/{importId}` for progress.
231264
232-
`type` selects the source. `dex` — history over a pool/pair's own on-chain market — is the
233-
only value today; other source types join this same endpoint later.
265+
`type` selects the kind of venue the history comes from, with the same two values as the
266+
`venueType` of `GET /exchanges`: `dex` — history over a pool/pair's own on-chain market —
267+
and `cex` — the history of a centralized exchange's market, kept by the platform. Each
268+
has its own block (`dex`, `cex`). The exchange id of a `cex` import has to be a centralized
269+
exchange of `GET /exchanges`; a decentralized one (`robinhood`, say) is `400`, and is imported
270+
with `type: dex`.
271+
272+
A `cex` import covers one instrument over a range: the API reads that range of the
273+
exchange's history, builds the bars of the requested `cadence` from the finest data it has
274+
for it, and stores the result as a dataset version, exactly as for an upload. `cex.dataType`
275+
picks tickers or klines and is also the resulting dataset's `type`. The range is capped by
276+
your tier like any import (`maxImportRangeHours` of `GET /account`).
234277
235278
A `dex` import has two data shapes, chosen by the top-level `cadence`:
236279
@@ -246,7 +289,8 @@ async def asyncio(
246289
Args:
247290
body (DatasetImportRequest): `POST /datasets/imports`'s request body. A common block plus
248291
one type-specific block,
249-
selected by `type` — `dex` is the only value today.
292+
selected by `type`: `dex` (block `dex`) or `cex` (block `cex`), the two `venueType`s of
293+
`GET /exchanges`.
250294
251295
Raises:
252296
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.

0 commit comments

Comments
 (0)