Auto-generated Python client for the QTSurfer API, built from the OpenAPI 3.1 spec with openapi-python-client on top of httpx.
pip install qtsurfer-api-client
Intentionally thin: one function per endpoint, 1:1 with the spec. For workflow orchestration (polling, retries, domain objects, unified errors), use qtsurfer-sdk.
- Sync-first, httpx-powered — same call site shape as the Java/TS siblings.
- Spec-driven — generated sources fetched from
QTSurfer/qtsurfer-apibyscripts/regenerate.sh. - Fully typed —
py.typedmarker,mypy --strictclean (consumers see precise types for every request/response). - Python 3.11+ — modern type hints, no compat shims.
pip install qtsurfer-api-clientOr with uv:
uv add qtsurfer-api-clientimport os
from qtsurfer.api.client import AuthenticatedClient
from qtsurfer.api.client.api.exchange import list_exchanges, list_instruments
client = AuthenticatedClient(
base_url="https://api.qtsurfer.net/v1", # Staging beta server
token=os.environ["QTSURFER_TOKEN"],
)
exchanges = list_exchanges.sync(client=client)
for ex in exchanges or []:
print(ex.id, ex.name)
instruments = list_instruments.sync(client=client, exchange_id="binance")
print(f"{len(instruments.data)} instruments on binance ({instruments.meta.segment.value})")Every endpoint above expects a short-lived JWT in the Authorization: Bearer …
header. Exchange a long-lived API key for one via authenticate:
import os
from qtsurfer.api.client import AuthenticatedClient
from qtsurfer.api.client.api.auth import authenticate
# AuthenticatedClient also drives the apikey header — set prefix="" so it
# sends `X-API-Key: <key>` instead of `Authorization: Bearer <key>`.
apikey_client = AuthenticatedClient(
base_url="https://api.qtsurfer.net/v1",
token=os.environ["QTSURFER_APIKEY"],
prefix="",
auth_header_name="X-API-Key",
)
token_response = authenticate.sync(client=apikey_client)
jwt = token_response.access_token # use this in subsequent callsFor production use, prefer the qtsurfer-sdk
auth(apikey) helper — it handles token refresh, env-var pickup
(QTSURFER_APIKEY), and pluggable token storage so callers don't reinvent any
of it on top of the raw client.
Each generated endpoint module exposes four entrypoints:
| Function | Returns |
|---|---|
sync(...) |
parsed model (or None on a defined error response) |
sync_detailed(...) |
full Response[...] (status, headers, parsed, content) |
asyncio(...) |
parsed model, awaitable |
asyncio_detailed(...) |
full Response[...], awaitable |
| Module | Operation | Method · Path |
|---|---|---|
api.auth |
authenticate |
POST /auth/token — exchange API key for a short-lived JWT |
api.account |
get_account |
GET /account |
api.account |
get_account_usage |
GET /account/usage |
api.exchange |
list_exchanges |
GET /exchanges |
api.exchange |
list_instruments |
GET /exchange/{exchangeId}/instruments (default spot segment) |
api.exchange |
list_segment_instruments |
GET /exchange/{exchangeId}/{segment}/instruments |
api.exchange |
download_tickers |
GET /exchange/{exchangeId}/tickers/{base}/{quote} |
api.exchange |
download_klines |
GET /exchange/{exchangeId}/klines/{base}/{quote} |
api.strategy |
get_strategy |
GET /strategy/{strategyId} |
api.strategy |
list_strategies |
GET /strategies — optional include_deleted returns deleted entries with deleted_at |
api.strategy |
delete_strategy |
DELETE /strategy/{strategyId} |
api.strategy |
get_strategy_code |
GET /strategy/{strategyId}/code |
api.strategy |
validate_strategy |
POST /strategy/{strategyId}/validate |
api.backtesting |
prepare_backtest |
POST /backtest/{exchangeId}/{type}/prepare |
api.backtesting |
get_prepare_status |
GET /backtest/{exchangeId}/{type}/prepare/{jobId} |
api.backtesting |
execute_backtest |
POST /backtest/{exchangeId}/{type}/execute |
api.backtesting |
cancel_backtest |
DELETE /backtest/{exchangeId}/{type}/execute/{jobId} |
api.backtesting |
get_backtest_result |
GET /backtest/{exchangeId}/{type}/execute/{jobId} |
api.backtesting |
execute_sweep |
POST /backtest/{exchangeId}/{type}/executeSweep/{requestId} |
api.backtesting |
get_sweep_result |
GET /backtest/{exchangeId}/{type}/executeSweep/{requestId}/{sweepId} |
api.backtesting |
cancel_sweep |
DELETE /backtest/{exchangeId}/{type}/executeSweep/{requestId}/{sweepId} |
api.backtesting |
get_sweep_sensitivity |
GET /backtest/{exchangeId}/{type}/executeSweep/{requestId}/{sweepId}/sensitivity |
api.backtesting |
get_sweep_run_equity_curve |
GET /backtest/{exchangeId}/{type}/executeSweep/{requestId}/{sweepId}/runs/{runIx}/equityCurve |
api.dataset |
create_dataset |
POST /datasets — create a dataset and get a presigned upload URL |
api.dataset |
list_datasets |
GET /datasets — optional include_deleted returns deleted entries with deleted_at |
api.dataset |
get_dataset |
GET /datasets/{datasetId} |
api.dataset |
delete_dataset |
DELETE /datasets/{datasetId} |
api.dataset |
open_dataset_upload |
POST /datasets/{datasetId}/uploads — open the next upload session |
api.dataset |
finalize_dataset_upload |
POST /datasets/{datasetId}/uploads/{uploadId}/finalize |
api.dataset |
get_dataset_upload |
GET /datasets/{datasetId}/uploads/{uploadId} |
api.dataset |
import_dataset |
POST /datasets/imports — create a dataset from source history |
api.dataset |
get_dataset_import |
GET /datasets/{datasetId}/imports/{importId} |
api.live_execution |
start_live |
POST /strategy/{strategyId}/live |
api.live_execution |
get_live |
GET /strategy/{strategyId}/live |
api.live_execution |
stop_live |
DELETE /strategy/{strategyId}/live |
api.live_execution |
list_live |
GET /live — page the caller's own runs |
api.live_execution |
get_live_run |
GET /live/{runId} — read one of the caller's runs |
api.live_execution |
list_public_live |
GET /live/public |
api.live_execution |
update_live |
PATCH /live/{runId} |
api.live_execution |
update_live_params |
PUT /live/{runId}/params |
api.live_execution |
send_live_command |
POST /live/{runId}/commands — deliver a transient command to a running strategy |
api.live_execution |
rotate_live_stream |
POST /live/{runId}/stream — rotate a leaked stream URL |
api.live_execution |
revoke_live_stream |
DELETE /live/{runId}/stream — permanently revoke a stream URL |
api.live_execution |
get_live_run_signals |
GET /live/{runId}/signals |
api.live_execution |
get_live_run_paper |
GET /live/{runId}/paper — read simulated account snapshots |
api.live_execution |
get_live_run_paper_equity |
GET /live/{runId}/paper/equity — page simulated equity history |
api.live_execution |
mint_live_connection_token |
POST /live/token |
Forty-seven of the spec's forty-eight operations, all reachable through qtsurfer.api.client.api
as listed. The exception is compileStrategy (POST /strategy), whose text/plain request body
openapi-python-client does not support, so no module is generated for it — call it through the
underlying httpx client.
Upload CSV or parquet ticker data and prepare/execute a backtest against it via the reserved
exchangeId: user value on the existing prepare_backtest/execute_backtest endpoints:
create_dataset returns a datasetId plus a presigned URL to PUT the file to directly (or a
gzip/zip containing exactly one file), then
finalize_dataset_upload kicks off ingest and get_dataset_upload polls until status is
ready or failed. PrepareRequest.instrument is optional for this reason — pass dataset_id
(and optionally dataset_version_id to pin a specific past version) instead when the exchange is
user. list_datasets/get_dataset never 404 for "none yet", same convention as
list_strategies; delete_dataset is a soft delete that doesn't disrupt a backtest already
running against one of the dataset's versions.
Pass include_deleted=True to list_datasets or list_strategies when reconciling a local
catalogue; deleted entries carry deleted_at, which distinguishes them from resources that never
existed. The default remains active entries only.
DatasetCreated contains only the metadata known immediately after creation (dataset_id, name,
type_, instrument) and its first upload session. Query get_dataset for version-derived range,
cadence, and current-version metadata after the relevant lifecycle stage completes. Once ready,
data_url is a presigned URL for the stored data and data_format tells whether it is lastra or
parquet; choose the reader from data_format, not from the uploaded file name.
To add a later version, call open_dataset_upload(dataset_id) to obtain a DatasetUploadSession.
It returns the currently open session again if a previous response was lost. After finalization,
open a new session before uploading again: finalizing an upload that already produced a version
returns 409.
import_dataset starts a source-side dataset import and returns DatasetImportCreated with the
new dataset and import ids. Poll get_dataset_import(dataset_id, import_id) until the status is
ready or failed. A ready response carries the resulting DatasetVersion; a failed response
carries its error. Dataset listings now include the current version's status, timestamp unit,
and coverage metrics, so select the timestamp decoder from timestamp_unit when reading stored
data.
start_live starts a compiled strategy against one live source. Use get_live to inspect its
latest state, get_live_run to read a specific run (including its monotonic updated_at_ms),
update_live and update_live_params to change it, and stop_live to stop it.
list_public_live is the unauthenticated listing for public runs. get_live_run_signals pages
recorded signals regardless of WebSocket relay: a 410 response means the supplied cursor has
expired, so restart from its availableSinceMs value. mint_live_connection_token creates a
short-lived WebSocket connection token for a run.
Paper trading is opt-in through StartLiveRequest.paper; it simulates balances and fills without
sending orders. Read current simulated accounts with get_live_run_paper, and page their equity
history oldest-first with get_live_run_paper_equity.
send_live_command requires the run owner and a strategy that implements
CommandRequestHandler. Commands are transient, are not persisted or replayed to replicas started
later, and should not replace update_live_params for state that must survive restarts. A 202
means accepted, not processed; 503 guarantees it was not sent. The operation has no idempotency
key, so avoid blind retries after an ambiguous network failure.
Set StartLiveRequest.stream=True to request a secret plain-WebSocket stream_url; treat it
like a password. rotate_live_stream replaces a possibly leaked URL, while revoke_live_stream
disables it permanently for that run. LiveRunDetail.stats is an optional periodic snapshot, so
its absence means no snapshot exists yet rather than zero activity.
StartLiveRequest.warm_from is a start-only replay window in seconds (0 through 3600) for
initialising strategy indicators. 0 disables replay; when omitted, the platform retains its
automatic current-quarter-hour warm-up. The effective integer is returned as warm_from by
start_live, get_live, and get_live_run; it is absent, never None, only for runs created
before this field existed. Changing it requires stopping and starting a new run — it is not a
update_live_params setting.
Each live-start source uses LiveSourceRequest; its instruments field is optional. Omit it to
let the platform use the strategy's declared instruments when available, or to read every
instrument on that exchange and segment otherwise. You can also send ["*"] for that automatic
selection. An explicit list supports one-sided wildcards such as */USDT and BTC/*. Symbols are
case-insensitive and spaces around them are ignored (btc/usdt is the same as BTC/USDT); invalid
pair syntax is rejected when the run starts. The returned LiveSource.instruments tells you what
the run actually reads.
Account.max_sweep_cartesian reports the account's maximum Cartesian sweep grid size. Live-run
responses also expose optional reason text when a run stops or fails.
Exact module/function names are produced from
operationIdin the OpenAPI spec. Runscripts/regenerate.shto refresh and checksrc/qtsurfer/api/client/_generated/api/for the authoritative listing.
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 CoverageWindows 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.
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.
execute_backtest accepts optional EquityCurveOptions (resample, differential, and
out_mode) to shape the returned ResultMap.equity_curve, plus params for one scalar strategy
property vector. ResultMap.params echoes that vector when one was supplied. Sweep submissions accept
EquityCurveRequest, which also controls which trial curves are retained. A retained trial curve
is exposed as an EquityCurveResult.url on its sweep row and may also be inline in the natural
view; inspect points or equities to tell. Use get_sweep_run_equity_curve to retrieve a chosen
transform. The returned EquityCurveResult.meta describes the shape actually served, including any
server-applied size guard, so treat it as authoritative over requested defaults.
POST /strategy(compileStrategy) is currently omitted by the generator because the spec declares its request body astext/plainandopenapi-python-clientonly emits JSON / form / multipart bodies. Call it directly via the underlyinghttpxclient (client.get_httpx_client().post("/strategy", content=src, headers={"Content-Type": "text/plain"})) until the spec is restructured. The otherapi.strategyoperations have no such restriction and generate normally.
These endpoints return raw Lastra 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:
from qtsurfer.api.client import AuthenticatedClient
from qtsurfer.api.client.api.exchange import download_tickers
client = AuthenticatedClient(base_url="https://api.qtsurfer.net/v1", token=token)
response = download_tickers.sync_detailed(
client=client,
exchange_id="binance",
base="BTC",
quote="USDT",
hour="2026-01-15T10",
)
with open("BTC_USDT_2026-01-15_h10.lastra", "wb") as f:
f.write(response.content)For very large segments, drop down to the underlying httpx.Client (client.get_httpx_client()) and stream:
with client.get_httpx_client().stream(
"GET",
"/exchange/binance/klines/BTC/USDT",
params={"hour": "2026-01-15T10", "format": "parquet"},
) as r:
r.raise_for_status()
with open("out.parquet", "wb") as f:
for chunk in r.iter_bytes():
f.write(chunk)Both Client and AuthenticatedClient accept the standard hooks of the upstream generator:
from qtsurfer.api.client import AuthenticatedClient
client = AuthenticatedClient(
base_url="https://api.qtsurfer.net/v1",
token=token,
timeout=httpx.Timeout(30.0),
verify_ssl=True,
headers={"X-Request-Id": "..."},
raise_on_unexpected_status=True,
)Need per-call customisation (e.g. swap the underlying httpx.Client for one with a custom transport)? Use client.with_httpx_client(my_httpx_client) or client.set_httpx_client(...).
The src/qtsurfer/api/client/_generated/ directory is a committed build artifact produced from the OpenAPI spec hosted at QTSurfer/qtsurfer-api. Never hand-edit it.
uv sync # install pinned dev deps
./scripts/regenerate.sh # fetch spec + regenerate + sync pyproject version
uv run ruff check src/ tests/
uv run mypy src/
uv run pytest -vGenerator configuration lives in codegen.config.yaml. The spec URL is hard-coded in scripts/regenerate.sh; point it at a tag/commit for fully reproducible builds.
| Command | Description |
|---|---|
uv sync |
Install dependencies from uv.lock |
./scripts/regenerate.sh |
Re-fetch spec + regenerate client |
uv run ruff check src/ tests/ |
Lint |
uv run ruff format src/ tests/ |
Format |
uv run mypy src/ |
Type-check (--strict) |
uv run pytest -v |
Run tests |
uv run python -m build |
Build wheel + sdist into dist/ |
pyproject.toml's version field is kept in lockstep with the info.version of the OpenAPI spec by scripts/regenerate.sh. Tags pushed to main (vX.Y.Z) trigger the PyPI publish workflow via OIDC trusted publishing.
Apache-2.0 — see LICENSE.