openapi: 3.1.0 info: title: QTSurfer API summary: QTSurfer backend services API version: 0.124.0 contact: name: QTSurfer Support email: support@qtsurfer.com url: 'https://qtsurfer.com/support' termsOfService: 'https://qtsurfer.com/terms' license: name: Apache-2.0 url: 'http://www.apache.org/licenses/LICENSE-2.0.html' servers: - url: 'https://api.qtsurfer.net/v1' description: >- Staging — the API this specification describes, and the one to develop against today. Generated clients take their default base URL from here. - url: 'https://api.qtsurfer.com/v1' description: >- Production — reserved, not yet serving. It is listed so the eventual address is known in advance; pointing a client at it today will not reach the API. Stay on staging until this one is announced. paths: '/auth/token': post: operationId: authenticate summary: Exchange API key for a short-lived JWT description: | Exchanges a long-lived API key for a short-lived JWT used by every other endpoint. This is the only endpoint that accepts an API key directly — callers should obtain a JWT here, then send it as `Authorization: Bearer ` to all other operations. The returned JWT carries the caller's subscription `tier` as a claim and expires after `expires_in` seconds. Callers should refresh the token before expiry (or on a `401` response) by calling this endpoint again. responses: '200': description: API key accepted; JWT returned. content: application/json: schema: $ref: '#/components/schemas/AuthTokenResponse' example: access_token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI3NmI5MDIwMy0wM2MyLTQ2ZjYtYjM2Ni05OTQ0ZjE2N2U4MTgiLCJzY29wZXMiOltdLCJ0aWVyIjoiZnJlZSIsImlhdCI6MTc3OTczNTQ2MiwiZXhwIjoxNzc5NzM5MDYyfQ.signature token_type: Bearer expires_in: 3600 scopes: [] tier: free '401': description: API key is invalid, revoked, or expired. content: application/json: schema: $ref: '#/components/schemas/AuthTokenError' example: code: invalid_apikey message: API key not recognized '429': description: Rate limit exceeded for this API key. security: - apiKeyAuth: [] tags: - Auth '/account': get: operationId: getAccount summary: Get your account's identity and tier limits description: | Your userId, current tier, and that tier's limits — no database call, safe to fetch on every page load. For live usage against these limits, see `_links.usage` (`GET /account/usage`). responses: '200': description: Your account content: application/json: schema: $ref: '#/components/schemas/Account' example: userId: "00000000-0000-0000-0000-000000000000" tier: "free" maxDatasets: 3 maxDatasetBytes: 52428800 maxTotalStorageBytes: 104857600 _links: self: href: "/v1/account" usage: href: "/v1/account/usage" security: - bearerAuth: [] tags: - Account '/account/usage': get: operationId: getAccountUsage summary: Get your live storage usage description: | How much of your account's shared storage pool (`GET /account`'s `maxTotalStorageBytes`) you're currently using. Datasets, strategy-execution signals, and registered strategies all count against the same total — they compete for the same underlying storage, so there's one number to watch, not one per resource type. Not guaranteed real-time — a just-completed upload or strategy execution may take a short moment to be reflected here. responses: '200': description: Your current usage content: application/json: schema: $ref: '#/components/schemas/AccountUsage' example: datasetsUsed: 2 datasetBytesUsed: 15728640 signalsUsed: 1 signalBytesUsed: 524288 strategiesUsed: 4 strategyBytesUsed: 40960 storageBytesUsed: 16293888 _links: self: href: "/v1/account/usage" account: href: "/v1/account" security: - bearerAuth: [] tags: - Account '/exchanges': get: operationId: listExchanges summary: List the available exchanges responses: '200': description: A JSON array of Exchanges content: application/json: schema: type: array items: $ref: '#/components/schemas/Exchange' example: - id: binance name: Binance description: Binance cryptocurrency exchange tags: - Exchange '/exchange/{exchangeId}/instruments': get: operationId: listInstruments summary: List an exchange's instruments (default spot segment) description: | "Give me binance instruments" — returns the exchange's DEFAULT segment (`spot`) in `data`, each instrument with per-data-type coverage and market info. `meta` confirms the served `segment` (`spot`); HAL `_links` carry `self` plus the `spot` / `futures` segment-discovery links. parameters: - name: exchangeId in: path description: ID of the exchange to retrieve instruments for required: true schema: type: string example: binance responses: '200': description: The default (spot) segment's instruments in `data`, `meta`, and HAL `_links` (self + spot/futures segment discovery) content: application/json: schema: $ref: '#/components/schemas/InstrumentListResponse' example: data: - id: BTC/USDT base: BTC quote: USDT coverage: tickers: from: "2026-04-10T21:00:00Z" to: "2026-07-09T20:29:05Z" klines: from: "2026-04-22T17:00:00Z" to: "2026-07-09T20:31:08Z" lastPrice: 84250.50 volume24h: 1234567.89 meta: updatedAt: "2026-07-09T19:09:07Z" exchange: binance segment: spot _links: self: href: /v1/exchange/binance/instruments spot: href: /v1/exchange/binance/spot/instruments futures: href: /v1/exchange/binance/futures/instruments '404': description: Exchange not found or instrument catalog not available content: application/json: schema: $ref: '#/components/schemas/ResponseError' tags: - Exchange '/exchange/{exchangeId}/{segment}/instruments': get: operationId: listSegmentInstruments summary: List an exchange segment's instruments description: | Returns the instruments for one market segment of the exchange, each with per-data-type coverage and market info. HAL `_links` carry `self` plus the `spot` / `futures` segment-discovery links; the default-segment shortcut is `GET /exchange/{exchangeId}/instruments` (spot). parameters: - name: exchangeId in: path description: ID of the exchange to retrieve instruments for required: true schema: type: string example: binance - name: segment in: path description: Market segment to list instruments for required: true schema: type: string enum: [spot, futures] example: spot responses: '200': description: An object with a `data` array of instrument details (each with per-data-type coverage) and a `meta` block content: application/json: schema: $ref: '#/components/schemas/InstrumentListResponse' example: data: - id: BTC/USDT base: BTC quote: USDT coverage: tickers: from: "2026-04-10T21:00:00Z" to: "2026-07-09T20:29:05Z" klines: from: "2026-04-22T17:00:00Z" to: "2026-07-09T20:31:08Z" lastPrice: 84250.50 volume24h: 1234567.89 - id: ETH/USDT base: ETH quote: USDT coverage: tickers: from: "2026-04-10T21:00:00Z" to: "2026-07-09T20:28:22Z" inactiveSince: "2026-07-09T20:28:22Z" klines: from: "2026-04-22T17:00:00Z" to: "2026-07-09T20:31:09Z" inactiveSince: "2026-07-09T20:31:09Z" lastPrice: 3120.75 volume24h: 456789.12 meta: updatedAt: "2026-07-09T19:09:07Z" exchange: binance segment: spot _links: self: href: /v1/exchange/binance/spot/instruments spot: href: /v1/exchange/binance/spot/instruments futures: href: /v1/exchange/binance/futures/instruments '404': description: Exchange, segment, or instrument catalog not found content: application/json: schema: $ref: '#/components/schemas/ResponseError' tags: - Exchange '/exchange/{exchangeId}/tickers/{base}/{quote}': get: operationId: downloadTickers summary: Download one hour of tickers for an instrument as a Lastra segment description: | Serves exactly one hour of raw ticker data for the given instrument on the requested exchange. The payload is a native [Lastra](https://github.com/QTSurfer/lastra-java) file — QTSurfer's columnar format for tick-precision timeseries — with no JSON envelope. One segment = one hour, aligned to UTC. The `hour` query parameter selects the segment and must match `YYYY-MM-DDTHH` (no minutes/seconds, no timezone suffix). Example: `hour=2026-01-15T10` returns `h10.lastra` for 2026-01-15, covering `[10:00:00Z, 11:00:00Z)`. Hours not yet available return `404`. A `format=parquet` query parameter switches the response to on-the-fly Parquet conversion via [lastra-convert](https://github.com/QTSurfer/lastra-convert) for clients that don't yet read Lastra. Lastra is the primary format and cheaper when the client can consume it. Clients: - [lastra-java](https://github.com/QTSurfer/lastra-java) — reference Java reader/writer with per-column codecs (ALP, Gorilla, delta-varint, ZSTD) and CRC32 integrity. - [lastra-ts](https://github.com/QTSurfer/lastra-ts) — TypeScript reader (~4 kB bundle, browser + Node.js). - [duckdb-lastra](https://github.com/QTSurfer/duckdb-lastra) — DuckDB extension for ad-hoc SQL over Lastra files. - [lastra-convert](https://github.com/QTSurfer/lastra-convert) — CLI + Java API for converting to/from Parquet, Reef, and CSV. - `curl -OJ` for offline dumps (the `Content-Disposition` header sets a descriptive filename). parameters: - name: exchangeId in: path required: true description: ID of the exchange (e.g. `binance`). schema: type: string example: binance - name: base in: path required: true description: Base asset symbol (first leg of the pair). schema: type: string example: BTC - name: quote in: path required: true description: Quote asset symbol (second leg of the pair). schema: type: string example: USDT - name: hour in: query required: true description: | Hour selector in `YYYY-MM-DDTHH` (UTC). The returned segment covers `[HH:00:00Z, HH+1:00:00Z)`. schema: type: string pattern: '^\d{4}-\d{2}-\d{2}T\d{2}$' example: '2026-01-15T10' - name: format in: query required: false description: | Response wire format. `lastra` (default) returns raw Lastra bytes. `parquet` returns Parquet via on-the-fly conversion using [lastra-convert](https://github.com/QTSurfer/lastra-convert). schema: type: string enum: [lastra, parquet] default: lastra example: lastra responses: '200': description: | One hour of tickers for the instrument. `Content-Type` is `application/vnd.lastra` by default or `application/vnd.apache.parquet` when `format=parquet` was requested. headers: Content-Disposition: description: | Attachment filename `{BASE}_{QUOTE}_{YYYY-MM-DD}_h{HH}.{ext}` where `{ext}` is `lastra` or `parquet` to match the format. schema: type: string content: application/vnd.lastra: schema: type: string format: binary application/vnd.apache.parquet: schema: type: string format: binary '400': description: Missing or malformed parameters (e.g. `hour` not `YYYY-MM-DDTHH`). content: application/json: schema: $ref: '#/components/schemas/ResponseError' '404': description: No Lastra segment exists for the requested instrument/hour. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '500': description: Unexpected I/O error serving the file. content: application/json: schema: $ref: '#/components/schemas/ResponseError' tags: - Exchange '/exchange/{exchangeId}/klines/{base}/{quote}': get: operationId: downloadKlines summary: Download one hour of klines for an instrument as a Lastra segment description: | Same shape and semantics as `/exchange/{exchangeId}/tickers/{base}/{quote}`, but serves klines (aggregated bars) instead of raw ticks. One [Lastra](https://github.com/QTSurfer/lastra-java) segment = one hour of klines at the exchange's native kline cadence, aligned to UTC. Klines use the same columnar layout as tickers — readers that handle one format read the other with the same code. Use this endpoint when a per-tick payload would be too large for the window of interest. parameters: - name: exchangeId in: path required: true description: ID of the exchange (e.g. `binance`). schema: type: string example: binance - name: base in: path required: true description: Base asset symbol. schema: type: string example: BTC - name: quote in: path required: true description: Quote asset symbol. schema: type: string example: USDT - name: hour in: query required: true description: | Hour selector in `YYYY-MM-DDTHH` (UTC). The returned segment covers `[HH:00:00Z, HH+1:00:00Z)`. schema: type: string pattern: '^\d{4}-\d{2}-\d{2}T\d{2}$' example: '2026-01-15T10' - name: format in: query required: false description: | Response wire format. `lastra` (default) returns raw Lastra bytes. `parquet` returns Parquet via on-the-fly conversion. schema: type: string enum: [lastra, parquet] default: lastra example: lastra responses: '200': description: | One hour of klines for the instrument. `Content-Type` is `application/vnd.lastra` by default or `application/vnd.apache.parquet` when `format=parquet`. headers: Content-Disposition: description: | Attachment filename `{BASE}_{QUOTE}_{YYYY-MM-DD}_h{HH}_klines.{ext}` where `{ext}` is `lastra` or `parquet` to match the format. schema: type: string content: application/vnd.lastra: schema: type: string format: binary application/vnd.apache.parquet: schema: type: string format: binary '400': description: Missing or malformed parameters (e.g. `hour` not `YYYY-MM-DDTHH`). content: application/json: schema: $ref: '#/components/schemas/ResponseError' '404': description: No Lastra segment exists for the requested instrument/hour. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '500': description: Unexpected I/O error serving the file. content: application/json: schema: $ref: '#/components/schemas/ResponseError' tags: - Exchange '/strategies': get: operationId: listStrategies summary: List your registered strategies description: | Every strategy you have registered and not deleted, most recently compiled first. Each entry carries the same provenance `GET /strategy/{strategyId}` does — `compiledAt`, `requiredSources` — but not its validation state, so listing stays cheap regardless of how many strategies you have. Check a specific strategy's validation with `GET /strategy/{strategyId}`. responses: '200': description: | Your registered strategies. An empty array if you have none — this is never a `404`. content: application/json: schema: type: object required: [strategies] properties: strategies: type: array items: $ref: '#/components/schemas/StrategySummary' example: strategies: - strategyId: 6bsh31ikwkuivhtgcoa6s4 compiledAt: '2026-08-19T10:15:00Z' requiredSources: [Ticker] - strategyId: 2ul144qe9tlwzu5anhwvc6 compiledAt: '2026-08-12T09:02:11Z' security: - bearerAuth: [] tags: - Strategy '/strategy': post: operationId: compileStrategy summary: Compile and register a strategy description: | Compiles raw strategy source and registers it, returning its `strategyId`. The source is either **Java** — a class extending a strategy base class — or **QTScript** (beta), a compact strategy language whose braced bodies are plain Java. QTScript source begins with the `strategy` keyword, and that is how the two are told apart: there is no separate endpoint and no header to set. Once registered, a strategy is used the same way whichever language it was written in. **This answers one question: is the source valid.** It compiles, registers, and hands back the id — nothing more. Whether the class can actually run is `POST /strategy/{strategyId}/validate`, and everything known about a strategy, validation included, is read from `GET /strategy/{strategyId}`. One place to ask, so there is no second answer to keep in step. For **Java**, the `strategyId` is derived from what the code *means*, not from how it is written. Adding a comment, inserting a blank line, re-indenting, reordering imports, or moving a method around all return the **same** id — you have not created a second strategy. Renaming a variable, changing an identifier's case, reordering fields, or reordering statements inside a method return a **different** one. For **QTScript**, the id is derived from the text, because indentation is part of its grammar. Only differences that cannot change the strategy are ignored: a byte-order mark, the style of line endings, whitespace at the end of a line, and blank lines before the first and after the last line. Anything else — a comment, the indentation, a blank line in between — returns a **different** id. Two rules follow, and they are worth designing around: - re-submitting a Java strategy you have only reformatted is free, and gives you back the id you already had, along with any validation already recorded against it; - the id says nothing about *behaviour*. Two sources that compute the same thing by different means are two strategies, because deciding otherwise would mean deciding program equivalence. The response also lists `declaredProperties` — the sweep/execute param keys this strategy is known to accept, so a caller can catch a typo'd key before submitting a sweep instead of only learning it from a rejected one. See `DeclaredProperty`: best-effort, not exhaustive. requestBody: description: The raw strategy source code required: true content: text/plain: schema: type: string description: Raw strategy source code, Java or QTScript responses: '200': description: Compiled and registered content: application/json: schema: type: object required: [strategyId] properties: strategyId: $ref: '#/components/schemas/strategyId' declaredProperties: type: array description: | What could be established about this strategy's sweep-key vocabulary without constructing it. See `DeclaredProperty` — best-effort, not exhaustive. items: $ref: '#/components/schemas/DeclaredProperty' example: strategyId: 6bsh31ikwkuivhtgcoa6s4 declaredProperties: - name: rsi.period description: RSI period defaultValue: '14' reflected: true min: 2 max: 50 step: 1 - name: enabled description: Enabled reflected: true '400': description: | The source is not valid; the message carries the diagnostics — the compiler's for Java, and for QTScript `Line N, Column M:` entries against your own source. Nothing is registered, so there is no id to look up afterwards. content: application/json: schema: $ref: '#/components/schemas/ResponseError' example: code: 400 message: 'Line 1, Column 18: syntax error' '429': description: Too many compilations in flight. Retry later. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Strategy '/strategy/{strategyId}/validate': post: operationId: validateStrategy summary: Check that a registered strategy can actually run description: | Instantiates the compiled class and drives it through a bounded synthetic series, so a wiring fault surfaces here instead of at your first backtest. The verdict — pass or fail, plus any engine notices — is recorded and served from `GET /strategy/{strategyId}`. **Idempotent.** If a verdict already exists for the current compilation it comes straight back with `200` and nothing is queued. Otherwise the check is queued and this returns `202`; poll `GET /strategy/{strategyId}` until `validation` is `passed` or `failed`. Recompiling supersedes a verdict, which makes this callable again — the old answer described bytecode that is no longer what would run. parameters: - name: strategyId in: path required: true description: The id returned by `POST /strategy` schema: $ref: '#/components/schemas/strategyId' responses: '200': description: Already validated; the recorded verdict, unchanged content: application/json: schema: $ref: '#/components/schemas/StrategyState' '202': description: | Validation queued. Not a terminal outcome — poll `GET /strategy/{strategyId}` until `validation` leaves `pending`. The body is a `StrategyState` carrying only what is known at this point: the id and `validation: pending`. **The status code, not the body, is what tells the two responses apart** — a `200` can also carry `validation: pending`, left by a check an earlier call queued. So `202` means *this call started a check*, while `pending` means only *a check is outstanding*. content: application/json: schema: $ref: '#/components/schemas/StrategyState' example: strategyId: 6bsh31ikwkuivhtgcoa6s4 validation: pending '404': description: No such registered strategy for this user content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Strategy '/strategy/{strategyId}': get: operationId: getStrategy summary: Get a strategy by id, including its validation state description: | Reports that the strategy is registered — implied by a `200` at all — and what validating it found. A `404` means one thing: no such registered strategy for this user. It is never a stale or expired answer; registration and verdict are stored durably, not cached. parameters: - name: strategyId in: path required: true description: The id returned by `POST /strategy` schema: $ref: '#/components/schemas/strategyId' responses: '200': description: Strategy state content: application/json: schema: $ref: '#/components/schemas/StrategyState' '404': description: No such registered strategy for this user content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Strategy delete: operationId: deleteStrategy summary: Release a registered strategy description: | Removes a strategy from `GET /strategy/{strategyId}` and `GET /strategies`. This is not undone by re-submitting the same source to `POST /strategy` — that registers a new strategy, with a new id. **Backtests you already ran against this strategy are unaffected.** Deleting it stops it from counting against your account and stops you from validating or re-running it under this id — it does not erase what already happened. Only removes a strategy you registered yourself. If you copied someone else's strategy (a shared/marketplace listing), deleting your copy never affects theirs, or anyone else's. parameters: - name: strategyId in: path required: true description: The id returned by `POST /strategy` schema: $ref: '#/components/schemas/strategyId' responses: '200': description: Deleted content: application/json: schema: type: object required: [strategyId, deleted] properties: strategyId: $ref: '#/components/schemas/strategyId' deleted: type: boolean enum: [true] example: strategyId: 6bsh31ikwkuivhtgcoa6s4 deleted: true '404': description: No such registered strategy for this user content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Strategy '/strategy/{strategyId}/code': get: operationId: getStrategyCode summary: Get a registered strategy's source, if you still have one to read description: | The exact source you last submitted for this id — the same text `POST /strategy` derives `strategyId` from, whitespace and comments included. **"If available", not "always".** A strategy you resolve only through a shared/marketplace listing you copied by reference carries no source of its own, and reads as a `404` here the same as a `strategyId` you never registered — that is the honest answer either way, since from this endpoint's point of view nothing is there to return. parameters: - name: strategyId in: path required: true description: The id returned by `POST /strategy` schema: $ref: '#/components/schemas/strategyId' responses: '200': description: The registered source content: application/json: schema: type: object required: [strategyId, code] properties: strategyId: $ref: '#/components/schemas/strategyId' code: type: string description: Raw strategy source code (Java or QTScript), exactly as registered. example: strategyId: 6bsh31ikwkuivhtgcoa6s4 code: | package strategy; public class EmaCrossStrategy extends AbstractTickerStrategy { ... } '404': description: No such registered strategy for this user, or nothing to read for this id content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Strategy '/backtest/{exchangeId}/{type}/prepare': post: operationId: prepareBacktest summary: Prepare backtest data description: | Enqueues a prepare task over the requested date range. Returns immediately with a `jobId`; poll `GET /backtest/{exchangeId}/{type}/prepare/{jobId}` for completion. The same params always return the same `jobId` (idempotent). Repeated calls with identical params do not enqueue duplicate work — they reuse the existing job. Every source in `DataSourceType` can be prepared, but not every one can then be run: `funding` data can be prepared and is rejected by `execute` and `executeSweep`. A `kline` prepare takes the bar width from `cadence` — see `PrepareRequest`. **`exchangeId: user` is reserved for your own uploaded data.** Instead of a managed exchange, it prepares from a dataset you created via `POST /datasets` (see the **Dataset** endpoints) — send `datasetId` in place of `instrument`. See `PrepareRequest` below for the two request shapes. parameters: - name: exchangeId in: path description: | ID of the exchange to prepare the backtesting for (e.g. `binance`), or the reserved value `user` to prepare from a dataset you uploaded instead of a managed exchange. required: true schema: type: string example: binance - name: type in: path description: The type of data source to prepare from required: true schema: $ref: '#/components/schemas/DataSourceType' requestBody: description: The required data to prepare a backtesting required: true content: application/json: schema: $ref: '#/components/schemas/PrepareRequest' responses: '202': description: Prepare task accepted (queued for processing) content: application/json: schema: $ref: '#/components/schemas/AcceptedJob' '400': description: | Invalid request or parameters. Also returned when `from` is older than the configured lookback window or `to` is in the future, or when `type` is `kline` and `cadence` is not one of the cadences kline data is available at (the message lists them). For `exchangeId: user`, also returned when the dataset's current upload has not finished ingesting, or `cadence` asks for a finer granularity than the dataset's own discovered cadence (or one that isn't an exact multiple of it), or the requested range exceeds your tier's range limit. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '404': description: | Exchange or data source type not found. For `exchangeId: user`, also returned when `datasetId` (or a pinned `datasetVersionId`) doesn't exist or isn't yours. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '429': description: | Rate limited. Returned when the global queue exceeds capacity or the user has too many active backtests. Does not apply to `exchangeId: user` — a dataset prepare reads an already-ingested file rather than enqueueing worker capacity. content: application/json: schema: $ref: '#/components/schemas/ResponseError' example: code: 429 message: "Too many active backtests. Wait for some to complete." security: - bearerAuth: [] tags: - Backtesting '/backtest/{exchangeId}/{type}/prepare/{jobId}': get: operationId: getPrepareStatus summary: Get the status of a prepare job description: | Retrieves the current state of the prepare job identified by `jobId`. Poll until `status` is `Completed`, `Failed`, or `Aborted`. For a dataset prepare (`exchangeId: user`), coverage is reported against the dataset's own cadence grid instead of hours — see `cadence`/`gaps`/`largestGapSteps` on `PrepareJobState`. parameters: - name: exchangeId in: path description: | ID of the exchange for the backtesting process, or the reserved value `user` for a dataset-backed prepare. required: true schema: type: string example: binance - name: type in: path description: The type of data source to prepare from required: true schema: $ref: '#/components/schemas/DataSourceType' - name: jobId in: path description: Job ID returned by `POST /prepare` required: true schema: type: string example: 13RBLGQlPnfDjO6wyKSX8i responses: '200': description: Current prepare job state content: application/json: schema: $ref: '#/components/schemas/PrepareJobState' example: contextId: "ctx_0bjmoxd4vahkgc0hnvdldh" status: "Completed" statusDetail: null size: 0 completed: 24 startTime: "2026-04-14T15:00:00Z" endTime: "2026-04-14T15:00:01Z" dataFrom: "2026-04-14T13:00:00Z" dataTo: "2026-04-14T15:30:05Z" coverageRatio: 0.994 totalHours: 168 hoursWithData: 167 hoursWithoutData: - hour: "2026-04-14T02:00:00Z" expected: 0 rationale: low_activity '404': description: Prepare job not found or expired content: application/json: schema: $ref: '#/components/schemas/ResponseError' '400': description: Invalid request or parameters content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Backtesting '/backtest/{exchangeId}/{type}/executeSweep/{requestId}': post: operationId: executeSweep summary: Execute a parameter sweep over prepared data description: | Runs a parameter matrix over the single immutable dataset identified by `requestId`. The backend expands and executes the matrix internally; clients poll the returned `sweepId` for incremental results. `type` must be `ticker` or `kline`; a `kline` sweep, walk-forward included, runs over bars of the cadence the request was prepared at. `funding` can be prepared but not swept yet: it is rejected with `400` before anything is queued. Supplying `walkForward` runs the sweep in a different mode entirely. Instead of scoring every parameter vector once over the whole range, the data is split into F sequential folds; each fold optimizes the full grid on its own window and then scores only its winner on the window immediately after — data that winner was not chosen on. It answers a harder question than a leaderboard: not "which parameters won", but "does re-optimizing this periodically actually work". Omit the block and nothing changes, including the response. The cost is the reason it is opt-in rather than always on: F folds × N vectors, so a 4-fold run over a 500-point grid is 2004 backtests where the plain sweep is 500. The request is rejected when `folds × totalRuns` exceeds the server's sweep budget. parameters: - name: exchangeId in: path required: true schema: type: string example: binance - name: type in: path required: true schema: $ref: '#/components/schemas/DataSourceType' - name: requestId in: path required: true description: Job ID returned by `POST /backtest/{exchangeId}/{type}/prepare`. schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ExecuteSweepRequest' responses: '202': description: Sweep accepted. The effective seed is returned for reproducibility. content: application/json: schema: $ref: '#/components/schemas/ExecuteSweepAccepted' '400': description: | Invalid sweep specification, a `type` that cannot be swept yet (`funding`), or the expanded grid exceeds the server limit. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '404': description: Prepared request not found or expired. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '429': description: Sweep queue or user concurrency limit reached. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Backtesting '/backtest/{exchangeId}/{type}/executeSweep/{requestId}/{sweepId}': get: operationId: getSweepResult summary: Get sweep progress and results description: | Returns incremental sweep progress. The default `ranked` view sorts and may truncate the display leaderboard. `order=natural` returns every available row, untruncated, ordered by deterministic `runIx`; use that view when materialising durable trial rows. The `ranked` view is ordered by **plateau score** by default, not by the raw objective. A plateau score is the objective of the worst run in a parameter point's immediate neighbourhood, so a point scores well only if the region around it also does — the highest raw score is frequently a spike that does not survive the parameters moving slightly. Pass `ranking=raw` for the unadjusted objective order. Rows in the `ranked` view carry `plateauScore` and `neighbourCount` when plateau ranking applied. Read them together: `neighbourCount: 0` means the point had no neighbours to compare against, so its plateau score is unevidenced rather than confirmed. Sweeps submitted before plateau ranking existed have no stored parameter grid to rebuild a neighbourhood from and are always ranked raw; the response's `ranking` field says which ordering was actually used. A sweep submitted with `walkForward` answers in a different shape, and the `walkForward` field on the response is what tells the two apart — it appears as soon as the sweep is accepted, before any fold has finished, so it is safe to branch on while polling. There the leaderboard is one row per completed fold: that fold's winner as it scored **out-of-sample**, with `runIx` carrying the fold index rather than a grid position. The in-sample runs behind those winners are not retained — they are an optimization's working set, and only the winner survives its fold. `ranking` is always `raw` and no plateau, DSR or PBO figure is reported: the out-of-sample scores are already the honest number, and layering a certification computed over F observations on top of them would overstate what was measured. parameters: - name: exchangeId in: path required: true schema: type: string example: binance - name: type in: path required: true schema: $ref: '#/components/schemas/DataSourceType' - name: requestId in: path required: true description: The `jobId` returned by `POST /backtest/{exchangeId}/{type}/prepare`. schema: type: string - name: sweepId in: path required: true schema: type: string - name: objective in: query required: false schema: type: string enum: [sharpe, sortino, pnl, maxdd] - name: order in: query required: false description: "`natural` is stable materialisation order; `ranked` is the display view." schema: type: string enum: [ranked, natural] default: ranked - name: ranking in: query required: false description: >- How the `ranked` view is ordered. `plateau` prefers points whose neighbourhood also scores well; `raw` uses the objective alone. Ignored when `order=natural`, which is always ordered by `runIx`. schema: type: string enum: [plateau, raw] default: plateau responses: '200': description: Current sweep snapshot and all currently available result rows for the selected view. content: application/json: schema: $ref: '#/components/schemas/ExecuteSweepResult' '404': description: Sweep not found or expired. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Backtesting delete: operationId: cancelSweep summary: Cancel a running parameter sweep description: Requests cancellation between parameter vectors. Completed rows remain readable. parameters: - name: exchangeId in: path required: true schema: type: string example: binance - name: type in: path required: true schema: $ref: '#/components/schemas/DataSourceType' - name: requestId in: path required: true description: The `jobId` returned by `POST /backtest/{exchangeId}/{type}/prepare`. schema: type: string - name: sweepId in: path required: true schema: type: string responses: '200': description: Cancellation requested. content: application/json: schema: type: object required: [status, sweepId] properties: status: type: string enum: [cancelling] sweepId: type: string '404': description: Sweep not found. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Backtesting '/backtest/{exchangeId}/{type}/executeSweep/{requestId}/{sweepId}/sensitivity': get: operationId: getSweepSensitivity summary: Get sweep sensitivity surfaces description: | How the objective moves as each parameter moves — the question a leaderboard cannot answer. A leaderboard says which point won; a sweep can spend its entire budget on an axis that never moved the objective at all, and showing only the top rows hides that completely. A **marginal** takes one axis and collapses every other one: for each value of that axis, it aggregates every run that used it, whatever the rest of the parameters were. A flat marginal means the axis is irrelevant over the range swept. `best`, `mean` and `worst` are all reported because them disagreeing is itself the signal — a value with a high `best` and a poor `mean` works only in specific company, which is an interaction between parameters and would be invisible behind a single number. A **heatmap** does the same over a pair of axes, where that interaction becomes visible directly. Served from the sweep's stored rows: no re-run, no engine call, and it works on a sweep still in flight — the aggregates then describe the runs finished so far. Aborted runs are excluded throughout, since a run that threw measured nothing and counting it as a bad outcome would invent evidence against a parameter value that was never really tested. This is a separate endpoint rather than extra fields on the result view because the two-dimensional half is quadratic in the axis count (N axes give N(N-1)/2 surfaces, each the product of two axes' value counts) and is not wanted on the poll that drives progress. parameters: - name: exchangeId in: path required: true schema: type: string example: binance - name: type in: path required: true schema: $ref: '#/components/schemas/DataSourceType' - name: requestId in: path required: true description: The `jobId` returned by `POST /backtest/{exchangeId}/{type}/prepare`. schema: type: string - name: sweepId in: path required: true schema: type: string - name: objective in: query required: false description: Which metric to aggregate. Defaults to the objective the sweep was submitted with. schema: type: string enum: [sharpe, sortino, pnl, maxdd] responses: '200': description: Sensitivity aggregates over the rows available so far. content: application/json: schema: $ref: '#/components/schemas/SweepSensitivity' '404': description: Sweep not found or expired. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Backtesting '/backtest/{exchangeId}/{type}/executeSweep/{requestId}/{sweepId}/runs/{runIx}/equityCurve': get: operationId: getSweepRunEquityCurve summary: Get one sweep trial's equity curve description: | The resource a leaderboard row's `equityCurve.url` points at — only reachable when that trial's curve was actually selected (`equityCurve.mode: topN` or `topPct` on the sweep submission, and this trial ranked among the winners). Returns the exact same `{points|timestamps+equities, meta}` shape a plain backtest's inline `equityCurve` carries. Query params reshape the response the same way a plain backtest's `equityCurve` options do. A param genuinely absent from the query string falls back to the `equityCurve` transform preference the sweep was submitted with — a param present but malformed does not fall back, it degrades the same way it always has. Above a server-side size threshold, the shape is forced regardless of either — `meta.outMode` in the response, not the query string or the submitted default, is the source of truth for what shape actually came back. parameters: - name: exchangeId in: path required: true schema: type: string example: binance - name: type in: path required: true schema: $ref: '#/components/schemas/DataSourceType' - name: requestId in: path required: true description: The `jobId` returned by `POST /backtest/{exchangeId}/{type}/prepare`. schema: type: string - name: sweepId in: path required: true schema: type: string - name: runIx in: path required: true description: The trial's `runIx`, as it appears on its leaderboard row. schema: type: integer minimum: 0 - name: outMode in: query required: false description: >- Requested JSON shape. Omit to use the sweep's submitted default; may be overridden either way by the server's size guard. schema: allOf: - $ref: '#/components/schemas/EquityCurveOutMode' default: ARRAY - name: resample in: query required: false description: >- Downsample to at most this many points (extrema-preserving — the global max/min and the exact first/last point are always kept). Omit to use the sweep's submitted default (itself omittable, for no downsampling). schema: type: integer minimum: 2 - name: differential in: query required: false description: >- Delta-encode both fields from the second point onward. Omit to use the sweep's submitted default. schema: type: boolean default: false responses: '200': description: The trial's equity curve, shaped per the resolved options. content: application/json: schema: $ref: '#/components/schemas/EquityCurveResult' '404': description: >- Sweep or `runIx` not found, or that trial's curve was never selected — indistinguishable from a caller's perspective. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Backtesting '/backtest/{exchangeId}/{type}/execute': post: operationId: executeBacktest summary: Execute a compiled strategy against a prepared dataset description: | Enqueues an execute task that runs the strategy identified by `strategyId` over the data prepared by the prepare job identified by `prepareJobId`. The instrument and date range are recovered from the prepare job — they do not need to be sent again. Returns immediately with a `jobId`; poll `GET /backtest/{exchangeId}/{type}/execute/{jobId}` for the result. `type` must be a source that can be executed: `ticker` or `kline`. A `kline` strategy is fed bars of the cadence its `prepareJobId` was prepared at — chosen by you when preparing, not by the strategy. `funding` can be prepared but not executed yet: it is rejected with `400` before anything is queued. Optionally takes `params`: strategy properties for this one run, applied without recompiling. This is how a sweep leaderboard winner gets re-run for its `equityCurve` — a sweep row carries the ten ranking metrics but never a curve, whatever its size. Compile once, call this endpoint N times with different `params`, and each response is an ordinary backtest result with the curve included. The same request (same `prepareJobId`, `strategyId`, `storeSignals`, `equityCurve`, `baseConfig`, `params`) always returns the same `jobId` (idempotent) — a request that omits `equityCurve`, `baseConfig` or `params` dedupes exactly as it did before those fields existed. Two different `params` vectors over one prepare are two different jobs, and `9` and `9.0` are the same one. The re-run is an independent execution rather than a replay of the sweep trial — the two paths do not share a simulator — but they are pinned to agree: one vector run both ways matches on every leaderboard metric, asserted as a regression test. Treat a difference as a bug worth reporting, not as expected behaviour. Optionally takes `baseConfig`, the same `SweepBaseConfig` shape `executeSweep` accepts — `initialFunding`, `feeRate`, `percentAmountToLock`, etc. — so the same object can be reused against either endpoint. This endpoint has one effective fee rate rather than a sweep's independent buy/sell legs: a `baseConfig` that resolves to different buy/sell rates, or sets a non-default `feeLeg`, is rejected with `400` rather than silently collapsed to one side. Works unchanged for a dataset-backed prepare (`exchangeId: user`) — the request body is identical either way, since the instrument and range are recovered from `prepareJobId`. parameters: - name: exchangeId in: path description: | ID of the exchange for the backtesting process, or the reserved value `user` if `prepareJobId` came from a dataset-backed prepare. required: true schema: type: string example: binance - name: type in: path description: The type of data source to execute from required: true schema: $ref: '#/components/schemas/DataSourceType' requestBody: description: Execute task parameters required: true content: application/json: schema: type: object required: [prepareJobId, strategyId] properties: prepareJobId: type: string description: Job ID returned by `POST /prepare` (must be in `Completed` state) example: 13RBLGQlPnfDjO6wyKSX8i strategyId: $ref: '#/components/schemas/strategyId' storeSignals: type: boolean description: | When true, the worker uploads emitted signals to object storage and the response includes `signalsUrl` / `signalsId` fields. Defaults to false. default: false equityCurve: $ref: '#/components/schemas/EquityCurveOptions' baseConfig: allOf: - $ref: '#/components/schemas/SweepBaseConfig' description: | Capital/fee/position-size overrides for this one run — the same shape `executeSweep`'s `baseConfig` accepts. Omit to run at the platform defaults (`initialFunding: 100`, `feeRate: 0.001`). `buyFeeRate`/`sellFeeRate`/`feeLeg` are accepted for shape compatibility with a sweep's `baseConfig`, but this endpoint has one fee-rate slot: a value that implies asymmetric buy/sell fees, or a non-default `feeLeg`, is rejected with `400`. params: type: object maxProperties: 64 description: | Strategy properties to apply to this run. Omit to run the strategy's declared defaults, which is exactly what a request without this field has always done. Each key is the `name` declared on the strategy's `@StrategyProperty`, which need NOT match the Java field it annotates — `GET`/`POST /strategy` returns `declaredProperties` for precisely this. A key naming no declared property is rejected: the job fails with the list of names the strategy does declare, rather than completing at the defaults and handing back a plausible result for parameters nobody chose. Scalars only — number, string or boolean. Ranges and lists belong to `executeSweep`; one request here is one run. `null` is not a value: leave the key out to keep a property at its default. Keys are made of letters, digits, `_`, `-` and dots, and may not be `strategyId`, `storeSignals`, `equityCurve`, `backtestEnabled` or `backtestFakeExecution` — those configure the job rather than the strategy. additionalProperties: $ref: '#/components/schemas/ScalarStrategyParamValue' example: ema.fast.period: 9 ema.slow.period: 21 risk.pct: 0.5 example: prepareJobId: "13RBLGQlPnfDjO6wyKSX8i" strategyId: "6bsh31ikwkuivhtgcoa6s4" storeSignals: false params: ema.fast.period: 9 ema.slow.period: 21 responses: '202': description: Execute task accepted (queued for processing) content: application/json: schema: $ref: '#/components/schemas/AcceptedJob' '404': description: Prepare job not found or expired content: application/json: schema: $ref: '#/components/schemas/ResponseError' '400': description: | Invalid request or parameters. Also returned for a `type` that cannot be executed yet (`funding`); the message names the sources that can. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '429': description: Rate limited (global queue at capacity or per-user limit reached) content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Backtesting '/backtest/{exchangeId}/{type}/execute/{jobId}': delete: operationId: cancelBacktest summary: Cancel a running backtest execution description: | Requests cancellation of the specified execution. The execution status will transition to `Aborted` once the cancellation is processed. Cancellation is asynchronous — poll the GET endpoint to confirm the final status. parameters: - name: exchangeId in: path required: true schema: type: string example: binance - name: type in: path required: true schema: $ref: '#/components/schemas/DataSourceType' - name: jobId in: path description: Job ID returned by `POST /execute` required: true schema: type: string example: 13RBLGQlPnfDjO6wyKSX8i responses: '200': description: Cancellation request accepted content: application/json: schema: type: object properties: status: type: string enum: [cancelling] example: cancelling jobId: type: string example: 13RBLGQlPnfDjO6wyKSX8i '404': description: Execution not found content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Backtesting get: operationId: getBacktestResult summary: Get the result of a backtest execution job description: | Retrieves the current state and results of the execute job identified by `jobId`. Poll until `state.status` is `Completed`, `Failed`, or `Aborted`. A `202` means the result is not readable yet — keep polling. It is never a terminal outcome, and it carries no `state`, so a poll loop that stops on a terminal status will not stop on it. parameters: - name: exchangeId in: path description: ID of the exchange for the backtesting process required: true schema: type: string example: binance - name: type in: path description: The type of data source to execute from required: true schema: $ref: '#/components/schemas/DataSourceType' - name: jobId in: path description: Job ID returned by `POST /execute` required: true schema: type: string example: 13RBLGQlPnfDjO6wyKSX8i responses: '200': description: Backtesting execution result content: application/json: schema: $ref: '#/components/schemas/BacktestJobResult' example: state: contextId: "ctx_2mnyblatqqw34kix2echpb" status: "Completed" statusDetail: null size: 73160 completed: 73158 startTime: "2026-03-18T13:21:28.958Z" endTime: "2026-03-18T13:21:29.605Z" results: hostName: "executor10" iops: 123956.53 strategyId: "strategy:00000000-0000-0000-0000-000000000000:2iyvtenlzh9dabqtxn7nbv" instrument: "BTC/USDT" notices: - level: WARN code: indicator.bar-data-on-ticker-path message: Indicator requires bar data but is on the ticker path provenance: execute pnlTotal: 42.75 pnlTotalPercent: 2.25 totalTrades: 156 winRate: 0.5833 sharpeRatio: 1.245 sortinoRatio: 1.872 cagr: 0.1534 maxDrawdown: 12.50 maxDrawdownPercent: 8.75 equityCurve: points: - timestamp: 1700000000000 equity: 100.0 - timestamp: 1700000060000 equity: 110.5 - timestamp: 1700000120000 equity: 90.25 meta: inputPointCount: 3 outputPointCount: 3 resampled: false differential: false outMode: ARRAY signalCount: 100000 signalsId: "00000000-0000-0000-0000-000000000000/exec/binance/3vsndwikcuaatjmb83fjtl" signalsUrl: "https://storage.qtsurfer.com/00000000-0000-0000-0000-000000000000/exec/binance/3vsndwikcuaatjmb83fjtl.parquet" signalsUpload: "Done" signalsUploadedAt: "2026-03-18T13:21:48.170Z" '202': description: | The job is known but its result is not readable yet — keep polling. Returned in two situations, both of which mean "ask again", never "you are done": the job has not produced its result yet, or the job reached a terminal status while its stored result could not be read back. The response body is an empty object: it deliberately carries no `state`, so a client cannot mistake it for a finished result. Treat any `202` as a signal to continue the poll loop under your existing timeout. Never treat it as a terminal outcome. content: application/json: schema: type: object additionalProperties: false example: {} '404': description: Execution job not found content: application/json: schema: $ref: '#/components/schemas/ResponseError' '400': description: Invalid request or parameters content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Backtesting '/datasets': post: operationId: createDataset summary: Create a dataset and get a URL to upload it to description: | Creates a dataset AND its first upload session in one call — a presigned URL your client PUTs the file to directly, no API credentials involved in that PUT. Call `POST /datasets/{datasetId}/uploads/{uploadId}/finalize` once the upload completes to kick off ingest. Losing this response loses nothing: calling this dataset's `POST /datasets/{datasetId}/uploads` returns the very same upload session again rather than opening a new one, as long as nothing has been finalized against it yet. v1 is ticker data only — `type` is not a request field, it is always `"ticker"` in the response. `instrument` must be a plain spot pair (`BASE/QUOTE`, exactly one `/`); derivative forms (e.g. `BTC/USDT:USDT`) are rejected. **Upload format.** A CSV with a header row, a parquet file with the same columns by name, or a lastra file — our own native columnar format, the same one a dataset's `dataUrl` hands back by default, so a downloaded dataset can be handed to another user to upload with no conversion in between. For CSV/parquet, required: `timestamp` (ISO-8601, or numeric epoch seconds/millis/micros — detected from the first row, then enforced for every later row), `close`. Optional: `open`, `high`, `low`, `volume`, `quoteVolume`, `bid`, `bidSize`, `ask`, `askSize`. A lastra upload carries its own fixed column set instead and only needs a timestamp series and a close series present. Cadence and timestamp unit are discovered from the data, not declared, for all three. A CSV upload is converted to our native columnar format (`lastra`) for storage. A parquet or lastra upload is stored as-is today. Either way, always check `dataFormat` on `GET /datasets/{datasetId}` and `GET /datasets/{datasetId}/uploads/{uploadId}` for which one `dataUrl` actually is, rather than assuming from how you uploaded it (a converted CSV and an uploaded lastra file both report `dataFormat: "lastra"`). The bytes PUT to `upload.url` may be that file directly, gzipped (`.gz`), or zipped (`.zip`, exactly one file inside — a dataset is one file regardless of how it travels). Format is detected from the decompressed content itself: there is no filename or `Content-Type` anywhere in this flow for a client to declare it with, so nothing needs to be sent besides the bytes. requestBody: description: The dataset to create required: true content: application/json: schema: type: object required: [name, instrument] properties: name: type: string description: A name unique among your datasets. `409` if already taken. example: "My BTC ticks" instrument: $ref: '#/components/schemas/Instrument' example: name: "My BTC ticks" instrument: "BTC/USDT" responses: '201': description: Dataset created, with an upload session ready for its first version content: application/json: schema: $ref: '#/components/schemas/DatasetCreated' example: datasetId: "ds_3f9a1c2e7b0d4a5f" name: "My BTC ticks" type: "ticker" instrument: "BTC/USDT" uploadId: "up_1a2b3c4d5e6f7a8b" upload: url: "https://storage.qtsurfer.com/00000000-.../uploads/up_1a2b3c4d5e6f7a8b/raw.csv?X-Amz-..." expiresInMinutes: 15 '400': description: Invalid request, or `instrument` is not a plain spot pair content: application/json: schema: $ref: '#/components/schemas/ResponseError' '409': description: You already have a dataset with this `name` content: application/json: schema: $ref: '#/components/schemas/ResponseError' '429': description: Your tier's dataset count limit is reached. Delete one, or upgrade. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Dataset get: operationId: listDatasets summary: List your datasets description: | Every dataset you have created and not deleted, most recently created first. Never a `404` — an empty array if you have none, same convention as `GET /strategies`. responses: '200': description: Your datasets content: application/json: schema: type: object required: [datasets] properties: datasets: type: array items: $ref: '#/components/schemas/Dataset' example: datasets: - datasetId: "ds_3f9a1c2e7b0d4a5f" name: "My BTC ticks" type: "ticker" instrument: "BTC/USDT" createdAt: "2026-08-20T09:00:00Z" currentVersionId: "dsv_8e2b4f19c6a03d7e" updatedAt: "2026-08-20T09:04:12Z" from: "2026-03-01T00:00:00Z" to: "2026-03-08T00:00:00Z" cadence: "1m" timestampUnit: "iso" status: "ready" bytes: 4831022 rows: 86400 gaps: 0 largestGapSteps: 0 security: - bearerAuth: [] tags: - Dataset '/datasets/{datasetId}': get: operationId: getDataset summary: Get a dataset by id description: Detail for one dataset, plus a self link. parameters: - name: datasetId in: path required: true description: The id returned by `POST /datasets` schema: type: string example: "ds_3f9a1c2e7b0d4a5f" responses: '200': description: Dataset detail content: application/json: schema: $ref: '#/components/schemas/DatasetWithLinks' example: datasetId: "ds_3f9a1c2e7b0d4a5f" name: "My BTC ticks" type: "ticker" instrument: "BTC/USDT" createdAt: "2026-08-20T09:00:00Z" currentVersionId: "dsv_8e2b4f19c6a03d7e" updatedAt: "2026-08-20T09:04:12Z" from: "2026-03-01T00:00:00Z" to: "2026-03-08T00:00:00Z" cadence: "1m" timestampUnit: "iso" status: "ready" bytes: 4831022 rows: 86400 gaps: 0 largestGapSteps: 0 dataUrl: "https://storage.qtsurfer.com/00000000-.../ds_3f9a1c2e7b0d4a5f/dsv_8e2b4f19c6a03d7e/ticker_BTC_USDT_1700000000000_1700086400000_1m.lastra?X-Amz-..." dataFormat: "lastra" _links: self: href: "/v1/datasets/ds_3f9a1c2e7b0d4a5f" '404': description: No such dataset for this user content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Dataset delete: operationId: deleteDataset summary: Delete a dataset description: | Soft-delete — the dataset stops appearing in `GET /datasets`/`GET /datasets/{datasetId}` and can no longer be prepared from, but its object data is reclaimed later rather than purged inline, so a backtest already running against one of its versions is not disrupted. parameters: - name: datasetId in: path required: true description: The id returned by `POST /datasets` schema: type: string example: "ds_3f9a1c2e7b0d4a5f" responses: '200': description: Deleted content: application/json: schema: type: object required: [datasetId, deleted] properties: datasetId: type: string deleted: type: boolean enum: [true] example: datasetId: "ds_3f9a1c2e7b0d4a5f" deleted: true '404': description: No such dataset for this user, or already deleted content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Dataset '/datasets/{datasetId}/uploads': post: operationId: openDatasetUpload summary: Open a new upload session for an existing dataset description: | Get a fresh presigned URL to upload a new version into a dataset you already have — a corrected file, or the next chunk of history. Behaves the same way `POST /datasets` does for a brand-new dataset's own upload: at most one upload session is open per dataset at a time, so calling this again before finalizing just hands back that same session rather than opening a second one — safe to call repeatedly if a response gets lost. Once a session has been finalized (successfully or not), the next call here opens a genuinely new one for that dataset's next version. parameters: - name: datasetId in: path required: true description: The id returned by `POST /datasets` schema: type: string example: "ds_3f9a1c2e7b0d4a5f" responses: '201': description: An upload session — new, or the one already open for this dataset content: application/json: schema: $ref: '#/components/schemas/DatasetUploadSession' example: uploadId: "up_1a2b3c4d5e6f7a8b" upload: url: "https://storage.qtsurfer.com/00000000-.../uploads/up_1a2b3c4d5e6f7a8b/raw.csv?X-Amz-..." expiresInMinutes: 15 '404': description: No such dataset for this user content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Dataset '/datasets/{datasetId}/uploads/{uploadId}/finalize': post: operationId: finalizeDatasetUpload summary: Finalize an uploaded file and start ingest description: | Call once the file has been PUT to the `upload.url` from `POST /datasets` (or from `POST /datasets/{datasetId}/uploads`). Enqueues ingest and returns immediately; poll `GET /datasets/{datasetId}/uploads/{uploadId}` for the result. Idempotent while the upload is still open — a repeat finalize before it has produced a version returns the same `jobId` rather than enqueueing a second ingest. Once it HAS produced a version, `uploadId` is spent: finalizing it again is a `409`, even with different bytes freshly PUT to the same URL — open a new upload session instead (`POST /datasets/{datasetId}/uploads`) rather than reusing a spent one. parameters: - name: datasetId in: path required: true description: The id returned by `POST /datasets` schema: type: string example: "ds_3f9a1c2e7b0d4a5f" - name: uploadId in: path required: true description: The `uploadId` returned by `POST /datasets` schema: type: string example: "up_1a2b3c4d5e6f7a8b" responses: '202': description: Ingest queued content: application/json: schema: type: object required: [jobId] properties: jobId: type: string example: jobId: "dataset-upload:00000000-.../ds_3f9a1c2e7b0d4a5f:up_1a2b3c4d5e6f7a8b" '404': description: | No such dataset for this user; `uploadId` was not issued for this dataset (never minted, or minted for a different one); or nothing was PUT to `upload.url` yet — a finalize with nothing to finalize. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '409': description: | `uploadId` already produced a version. The error message names it. Open a new upload session (`POST /datasets/{datasetId}/uploads`) for anything new. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '413': description: The uploaded file exceeds your tier's size limit for a dataset. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '429': description: | Your account's total storage limit (`GET /account`'s `maxTotalStorageBytes`) is reached or would be exceeded. Delete a dataset to free space, or upgrade. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Dataset '/datasets/{datasetId}/uploads/{uploadId}': get: operationId: getDatasetUpload summary: Get the state of an upload/ingest description: | Poll after `POST .../finalize` until `status` is `ready` or `failed`. Also reports `uploading` (finalize not called yet, but the file was PUT) before you finalize at all. parameters: - name: datasetId in: path required: true description: The id returned by `POST /datasets` schema: type: string example: "ds_3f9a1c2e7b0d4a5f" - name: uploadId in: path required: true description: The `uploadId` returned by `POST /datasets` schema: type: string example: "up_1a2b3c4d5e6f7a8b" responses: '200': description: Current upload/ingest state content: application/json: schema: $ref: '#/components/schemas/DatasetUploadState' example: uploadId: "up_1a2b3c4d5e6f7a8b" status: "ready" version: datasetId: "ds_3f9a1c2e7b0d4a5f" id: "dsv_8e2b4f19c6a03d7e" bytes: 4831022 rows: 86400 cadence: "1s" timestampUnit: "iso" gaps: 0 largestGapSteps: 0 dataUrl: "https://storage.qtsurfer.com/00000000-.../ds_3f9a1c2e7b0d4a5f/dsv_8e2b4f19c6a03d7e/ticker_BTC_USDT_1700000000000_1700086400000_1m.lastra?X-Amz-..." dataFormat: "lastra" '404': description: | No such dataset for this user, or genuinely nothing is known about this `uploadId` — no version, no in-flight job, and nothing was ever PUT to its upload URL. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Dataset '/datasets/imports': post: operationId: importDataset summary: Create a dataset by importing history instead of uploading it description: | A second way to get data into a dataset, alongside `POST /datasets`: instead of `PUT`ting a file yourself, ask the API to go fetch history on your behalf. Creates the dataset and starts the fetch in the same call — there is no separate upload step, and the result lands as a dataset version indistinguishable from an uploaded one once it's ready. Poll `GET /datasets/{datasetId}/imports/{importId}` for progress. `type` selects the source. `dex` — history over a pool/pair's own on-chain market — is the only value today; other source types join this same endpoint later. A `dex` import has two data shapes, chosen by the top-level `cadence`: * Omitted/blank (default) — on-chain swap history, replayed directly from the pool/pair's own chain. Cadence is native, not resampled: each swap keeps the timestamp it happened at rather than being bucketed into candles, so the resulting version's `cadence` is `rt` unless the swaps happen to sit on a fixed grid (see `DatasetVersion.cadence`). * One of `1s` / `1m` / `5m` — pre-aggregated candles at that width instead of raw trades. The resulting dataset's `type` is `klines`, not `ticker`. Not every network supports every cadence yet — an unsupported combination fails asynchronously, same as an unresolvable pool (see the `failed` status on the poll endpoint below). requestBody: description: What to fetch, and where from required: true content: application/json: schema: $ref: '#/components/schemas/DatasetImportRequest' examples: onChain: summary: On-chain swap history (native cadence) value: name: "weth-usdc-week" instrument: "WETH/USDC" from: "2026-08-01T00:00:00Z" to: "2026-08-08T00:00:00Z" type: "dex" dex: network: "ethereum" id: "uniswap" version: "v3" contract: "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640" candles: summary: Pre-aggregated 1-second candles value: name: "weth-usdc-1s" instrument: "WETH/USDC" from: "2026-08-01T00:00:00Z" to: "2026-08-01T06:00:00Z" cadence: "1s" type: "dex" dex: network: "ethereum" contract: "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640" responses: '202': description: Dataset created, fetch started content: application/json: schema: $ref: '#/components/schemas/DatasetImportCreated' example: datasetId: "ds_3f9a1c2e7b0d4a5f" importId: "imp_01j9z1x2y3z4a5b6c7d8e9f0g1" jobId: "dataset-import:00000000-.../ds_3f9a1c2e7b0d4a5f:imp_01j9z1x2y3z4a5b6c7d8e9f0g1" status: "fetching" '400': description: | Invalid request; `instrument` isn't a plain spot pair; `from >= to`; `cadence` present but not one of its supported values; the requested range exceeds your tier's import range ceiling; the requested range's rough size estimate exceeds your tier's row limit; `dex.network`/`dex.id` not one of their supported values; `dex.contract`/`dex.factory` fail basic shape validation; or, when `cadence` is omitted, `dex.id`/`dex.version` missing. Whether the pool/pair actually exists and resolves — and, for a candle `cadence`, whether that combination is actually servable on the requested `network` — is checked later, asynchronously — see the `failed` status on the poll endpoint below. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '409': description: You already have a dataset with this `name` content: application/json: schema: $ref: '#/components/schemas/ResponseError' '429': description: | Your tier's dataset count limit is reached, or your account's total storage limit (`GET /account`'s `maxTotalStorageBytes`) is already reached. Delete a dataset to free a slot or space, or upgrade. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Dataset '/datasets/{datasetId}/imports/{importId}': get: operationId: getDatasetImport summary: Get the state of an import/ingest description: | Poll after `POST /datasets/imports` until `status` is `ready` or `failed`. An import spends real time fetching from its source before anything is even staged — `fetching` is the one status only an import ever reports; `ingesting`/`ready`/`failed` mean exactly what they do on `GET /datasets/{datasetId}/uploads/{uploadId}`, since an import re-enters that same ingest chain once it has fetched and staged its data. parameters: - name: datasetId in: path required: true description: The id returned by `POST /datasets/imports` schema: type: string example: "ds_3f9a1c2e7b0d4a5f" - name: importId in: path required: true description: The `importId` returned by `POST /datasets/imports` schema: type: string example: "imp_01j9z1x2y3z4a5b6c7d8e9f0g1" responses: '200': description: Current fetch/ingest state content: application/json: schema: $ref: '#/components/schemas/DatasetImportState' example: importId: "imp_01j9z1x2y3z4a5b6c7d8e9f0g1" status: "ready" version: datasetId: "ds_3f9a1c2e7b0d4a5f" id: "dsv_8e2b4f19c6a03d7e" bytes: 4831022 rows: 604800 cadence: "rt" timestampUnit: "us" gaps: 0 largestGapSteps: 0 dataUrl: "https://storage.qtsurfer.com/00000000-.../ds_3f9a1c2e7b0d4a5f/dsv_8e2b4f19c6a03d7e/ticker_WETH_USDC_....lastra?X-Amz-..." dataFormat: "lastra" '404': description: | No such dataset for this user, or genuinely nothing is known about this `importId`. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Dataset components: schemas: ResponseError: description: General response error type: object required: - code - message properties: code: description: Status code type: integer example: 400 message: description: Error description type: string example: Invalid request ScalarStrategyParamValue: description: A scalar strategy property value for one execution. oneOf: - type: number - type: string - type: boolean Instrument: description: Exchange instrument identifier (e.g. a currency pair) type: string example: BTC/USDT InstrumentListResponse: description: HAL-style response envelope for the instruments listing type: object required: - data - meta - _links properties: data: type: array description: The list of instruments for the segment items: $ref: '#/components/schemas/InstrumentDetail' meta: $ref: '#/components/schemas/InstrumentListMeta' _links: $ref: '#/components/schemas/InstrumentLinks' InstrumentListMeta: description: Metadata describing the instruments listing type: object required: - updatedAt - exchange - segment properties: updatedAt: type: string format: date-time description: When this listing was last refreshed example: "2026-07-09T19:09:07Z" exchange: type: string description: The exchange the instruments belong to example: binance segment: type: string enum: [spot, futures] description: The market segment served in `data` example: spot InstrumentLinks: description: HAL `_links` — segment discovery for the instruments listing type: object required: - self properties: self: allOf: - $ref: '#/components/schemas/HalLink' description: Link to this listing spot: allOf: - $ref: '#/components/schemas/HalLink' description: Link to the spot instruments listing. Present when the exchange has a spot segment. futures: allOf: - $ref: '#/components/schemas/HalLink' description: Link to the futures instruments listing. Present only when the exchange has a futures segment. StrategyLinks: description: | HAL `_links` for a strategy — present on a full `StrategyState` body (`GET /strategy/{strategyId}`, and `POST /strategy/{strategyId}/validate`'s already-validated `200`), absent from that same endpoint's `202` — a deliberately partial stub carrying only what is known before a check has even started. Following `code` can still `404` once present: it documents its own honest "nothing to return" for a strategy with no source of its own (a `REFERENCE` marketplace copy, or one resolved only through the platform's shared pool). This link says where to look, not that something is there. type: object required: - code properties: code: allOf: - $ref: '#/components/schemas/HalLink' description: Link to this strategy's registered source, `GET /strategy/{strategyId}/code`. HalLink: description: A HAL link object (Hypertext Application Language) type: object required: - href properties: href: type: string format: uri-reference description: The link target as an absolute-path URI reference (resolve against the API base). A URI Template (RFC 6570) when `templated` is true. example: /v1/exchange/binance/spot/instruments templated: type: boolean description: True when `href` is an RFC 6570 URI Template. example: false Account: type: object description: | Your identity and tier limits. No database call behind this one — safe to fetch on every page load. Live usage against these limits is a separate resource, `GET /account/usage`, deliberately: usage changes on every upload/execution and costs a query to compute, this one doesn't. required: [userId, tier, maxDatasets, maxDatasetBytes, maxTotalStorageBytes, _links] properties: userId: type: string description: Your account id — the JWT `sub` claim. example: "00000000-0000-0000-0000-000000000000" tier: type: string description: Your current subscription tier. example: "free" maxDatasets: type: integer description: Maximum number of active datasets your tier allows. example: 3 maxDatasetBytes: type: integer format: int64 description: Maximum size, in bytes, of a single dataset version. example: 52428800 maxTotalStorageBytes: type: integer format: int64 description: | Maximum combined storage, in bytes, across every dataset, strategy-execution signal, and registered strategy on your account — one shared pool, not a separate cap per resource type, since they all compete for the same underlying storage. See `GET /account/usage`'s `storageBytesUsed` for your current usage against this number. example: 104857600 _links: $ref: '#/components/schemas/AccountLinks' AccountLinks: description: HAL `_links` for `GET /account` type: object required: [self, usage] properties: self: allOf: - $ref: '#/components/schemas/HalLink' description: Link to this resource. usage: allOf: - $ref: '#/components/schemas/HalLink' description: Link to your live usage, `GET /account/usage`. AccountUsage: type: object description: | Your live usage of the shared storage pool `GET /account`'s `maxTotalStorageBytes` caps. Not guaranteed real-time — a just-completed upload or strategy execution may take a short moment to be reflected here. required: - datasetsUsed - datasetBytesUsed - signalsUsed - signalBytesUsed - strategiesUsed - strategyBytesUsed - storageBytesUsed - _links properties: datasetsUsed: type: integer description: Active datasets counted — the same set `GET /account`'s `maxDatasets` limits. example: 2 datasetBytesUsed: type: integer format: int64 description: Combined bytes of every active dataset's current version. example: 15728640 signalsUsed: type: integer description: Recorded strategy-execution signal uploads. example: 1 signalBytesUsed: type: integer format: int64 description: Combined bytes of every recorded signal upload. example: 524288 strategiesUsed: type: integer description: Registered strategies (see `GET /strategies`). example: 4 strategyBytesUsed: type: integer format: int64 description: | Combined bytes of each registered strategy's source plus its latest compiled bytecode. Superseded (non-latest) compilations aren't counted. example: 40960 storageBytesUsed: type: integer format: int64 description: | `datasetBytesUsed + signalBytesUsed + strategyBytesUsed` — the number checked against `GET /account`'s `maxTotalStorageBytes`. example: 16293888 _links: $ref: '#/components/schemas/AccountUsageLinks' AccountUsageLinks: description: HAL `_links` for `GET /account/usage` type: object required: [self, account] properties: self: allOf: - $ref: '#/components/schemas/HalLink' description: Link to this resource. account: allOf: - $ref: '#/components/schemas/HalLink' description: Link to your tier limits, `GET /account`. InstrumentDetail: description: Exchange instrument with per-data-type coverage and market info type: object required: - id - base - quote properties: id: type: string description: Instrument identifier (e.g. currency pair) example: BTC/USDT base: type: string description: Base currency example: BTC quote: type: string description: Quote currency example: USDT coverage: $ref: '#/components/schemas/InstrumentCoverage' lastPrice: type: number format: double description: Last traded price example: 84250.50 volume24h: type: number format: double description: Trading volume in the last 24 hours (in quote currency) example: 1234567.89 InstrumentCoverage: description: Time coverage of available data for this instrument, per data type type: object properties: tickers: allOf: - $ref: '#/components/schemas/CoverageWindow' description: Coverage of ticker data klines: allOf: - $ref: '#/components/schemas/CoverageWindow' description: Coverage of kline (candlestick) data CoverageWindow: description: The time range of available data for a single data type type: object properties: from: type: string format: date-time description: Earliest timestamp with data available example: "2026-04-10T21:00:00Z" to: type: string format: date-time description: Latest timestamp with data available example: "2026-07-09T20:31:08Z" inactiveSince: type: string format: date-time description: If the instrument stopped producing this data type (delisted/inactive), the timestamp it went inactive. Optional — omitted while the instrument is active. example: "2026-06-30T12:00:00Z" Exchange: description: Exchange service provider type: object required: - id - name properties: id: description: Unique identifier for the exchange type: string example: binance name: description: Name of the exchange type: string example: Binance description: description: Description of the exchange type: string example: Binance cryptocurrency exchange DataSourceType: type: string description: | Managed exchange data sources available for backtesting. * `ticker` — trades. Can be prepared, executed and swept. * `kline` — aggregated bars (candlesticks). Can be prepared, executed and swept. A run reads bars of the `cadence` the data was prepared at: you choose the bar width when you prepare, and the strategy does not fix it. See `PrepareRequest.cadence` for the accepted values. * `funding` — funding rates. Can be **prepared but not executed or swept yet**: a `funding` request to `execute` or `executeSweep` is rejected with `400` before anything is queued, and the message names the sources that can be run. enum: - ticker - kline - funding example: ticker PrepareRequest: type: object description: | Two shapes, chosen by the `exchangeId` path segment. Against a managed exchange, `instrument` is required and `datasetId`/`datasetVersionId` are ignored. Against the reserved `exchangeId: user`, send `datasetId` instead of `instrument` — `instrument` is ignored there, since it comes from the dataset itself. required: [from, to] properties: instrument: allOf: - $ref: '#/components/schemas/Instrument' description: | Required unless `exchangeId` is the reserved value `user`, in which case send `datasetId` instead. datasetId: type: string description: | Only for `exchangeId: user`: the id of a dataset created via `POST /datasets`, in place of `instrument`. Ignored against a managed exchange. example: "ds_3f9a1c2e7b0d4a5f" datasetVersionId: type: string description: | Only for `exchangeId: user`, and optional even then: pins a specific past version of the dataset instead of its current one. Defaults to the dataset's current version. example: "dsv_8e2b4f19c6a03d7e" from: type: string description: | Start date for the preparation process. Supports the following formats: - ISO-8601 (e.g. 2024-12-14T23:59:59Z) - ISO DATE (e.g. 2024-12-14) - BASIC ISO DATE (e.g., 20241214) example: "2024-12-13T00:00:00Z" to: type: string description: | End date for the preparation process. Supports the following formats: - ISO-8601 (e.g. 2024-12-14T23:59:59Z) - ISO DATE (e.g. 2024-12-14) - BASIC ISO DATE (e.g., 20241214) example: "2024-12-14" cadence: type: string description: | Output bar cadence for the prepared range. Coarser cadences are produced on demand by resampling the source and stored alongside the native blob in cache. A target finer than the source, or not an exact multiple of it, returns `400`. What's accepted, and what omitting it means, depends on the source: * Managed exchange, `ticker` or `funding` — one of `1s`, `5s`, `1m`, `3m`, `5m`, `15m`, `30m`, `1h`, `2h`, `4h`, `8h`, `12h`, `1d`, `1w`, `1q`; any other label returns `400`. Omitted = `1s`, the publisher's native cadence. * Managed exchange, `kline` — one of `1s`, `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d`; any other label, including `5s`, `3m` and `8h`, returns `400` and names the accepted ones. Omitted = `1s`. This is the width of the bars a run reads: it is chosen here, once, and the same strategy can be run at several cadences by preparing the range at each. * Dataset (`exchangeId: user`) — omitted = the dataset version's own discovered `cadence` (see `DatasetVersion.cadence`), served as-is. Any cadence equal to or coarser than it and an exact multiple of it is accepted, including ones outside the managed-exchange list (e.g. `15s`); an `rt` dataset can be resampled to any fixed cadence. example: instrument: BTC/USDT from: "2024-12-13T00:00:00Z" to: "2024-12-14T00:00:00Z" cadence: "1m" JobState: type: object description: Information about a single job required: - contextId - status - size - completed properties: contextId: type: string description: >- Identifier for the job's execution context. Its current shape is a colon-delimited string encoding the data source type, an internal user id, the exchange, the job id, and the instrument — but that structure is not a committed contract and may change without notice. Treat it as an opaque token: store and pass it back, don't parse it. example: "jctx:ticker:00000000-0000-0000-0000-000000000000:binance:5ikyamio8b3v9wcnfxztzg:btc/usdt:0vicnz3thzhrqvfczks1pu" status: type: string description: | Current status of the job. Treat `Completed | Aborted | Failed` as terminal; `New | Started` mean keep polling. A single-instrument prepare is always terminal (`Completed`) — decide from `PrepareJobState.coverageRatio` (or `dataFrom`/`dataTo` against an `rt` dataset, which has no ratio), not by polling. enum: - New - Started - Completed - Aborted - Failed example: "Completed" statusDetail: type: ['string', 'null'] description: Detailed status information, if available example: "Job completed with error code 5001" size: type: integer description: Total size of the data being prepared example: 100 completed: type: integer description: The amount of data processed so far example: 50 startTime: type: ['string', 'null'] format: date-time description: Timestamp for when the preparation started example: "2025-01-04T14:00:00Z" endTime: type: ['string', 'null'] format: date-time description: Timestamp for when the preparation finished example: "2025-01-04T14:00:20Z" PrepareJobState: description: | State of a single-instrument prepare job — the `JobState` shape plus a coverage summary. A single-instrument prepare is always terminal (`status: Completed`): the client decides what to do from `coverageRatio` (e.g. execute if it is at or above a chosen threshold) rather than polling for missing hours that may never arrive — a missing hour for one instrument usually means low activity, not missing data. **Two coverage shapes, by exchange vs. dataset.** Against a managed exchange, coverage is walked hour by hour: `totalHours`/`hoursWithData`/`hoursWithoutData`. Against a dataset-backed prepare (`exchangeId: user`), coverage is reported on the dataset's own cadence grid instead — hour-walking a daily dataset would report `1/24` and read as broken — via `cadence`/`gaps`/`largestGapSteps`; `totalHours`/`hoursWithData`/ `hoursWithoutData` are absent in that case. `dataFrom`/`dataTo` are present either way, and `coverageRatio` too, computed accordingly — except against a dataset whose `cadence` is `rt`: with no fixed step there is no expected row count to measure against, so `coverageRatio` is absent and `gaps`/`largestGapSteps` are `0`. allOf: - $ref: '#/components/schemas/JobState' - type: object properties: dataFrom: type: ['string', 'null'] format: date-time description: Start of the available data range for the prepared instrument. example: "2026-04-14T13:00:00Z" dataTo: type: ['string', 'null'] format: date-time description: End of the available data range for the prepared instrument. example: "2026-04-14T15:30:05Z" coverageRatio: type: number format: double minimum: 0 maximum: 1 description: | Against a managed exchange: `hoursWithData / totalHours` in `[0,1]` (`1.0` when `totalHours` is 0), the fraction of hours in the requested range that have served data. Against a dataset (`exchangeId: user`): `rows / expectedStepsAtCadence` over the dataset version's own range — echoing what ingest computed once, not recomputed against a narrower prepare request. Absent for an `rt` dataset (no fixed step, so no expected row count). example: 0.994 totalHours: type: integer description: | Number of whole hours in the requested prepare range. Managed exchanges only — absent for a dataset-backed prepare. example: 168 hoursWithData: type: integer description: | Number of hours in the range that have data. Managed exchanges only — absent for a dataset-backed prepare. example: 167 cadence: type: string description: | The dataset version's own discovered cadence — a fixed grid (e.g. `1m`, `1h`) or `rt` (see `DatasetVersion.cadence`). Only present for a dataset-backed prepare (`exchangeId: user`). example: "1m" gaps: type: integer description: | Number of gaps in the dataset version at its own cadence, as discovered at ingest time. `0` for `rt`. Only present for a dataset-backed prepare. example: 0 largestGapSteps: type: integer description: | The largest gap in the dataset version, in units of its own cadence step. `0` for `rt`. Only present for a dataset-backed prepare. example: 0 hoursWithoutData: type: array description: | One entry per hour in the range that has no data, with a rationale. Managed exchanges only — absent for a dataset-backed prepare. items: type: object properties: hour: type: string format: date-time description: The hour (UTC, hour-aligned) that has no data. example: "2026-04-14T02:00:00Z" expected: type: integer description: | Expected row count for the hour (currently always 0; reserved for future use). The rationale never depends on it. example: 0 rationale: type: string description: | Why the hour has no data. `pending_conversion`: data for this hour is still being produced — a re-poll may fill it. `low_activity`: the instrument did not trade that hour. `unknown`: no data to classify by. enum: - pending_conversion - low_activity - unknown example: low_activity SweepAxis: description: A numeric range or an explicit list of values for one strategy property. oneOf: - type: object required: [from, to, step] additionalProperties: false properties: from: type: number format: double to: type: number format: double step: type: number format: double exclusiveMinimum: 0 - type: object required: [values] additionalProperties: false properties: values: type: array minItems: 1 items: oneOf: - type: number - type: boolean SweepSpecRequest: type: object required: [params] properties: sampler: type: string enum: [grid, random, lhs] default: grid seed: type: integer format: int64 minimum: -9007199254740991 maximum: 9007199254740991 description: | Reproducibility seed. If omitted, the server generates one with Java's `L64X128MixRandom` generator and returns the effective value. The range is limited to JavaScript-safe integers so generated clients can replay it exactly. samples: type: integer minimum: 1 description: Number of samples for `random` and `lhs`; ignored by `grid`. objective: type: string enum: [sharpe, sortino, pnl, maxdd] default: sharpe params: type: object minProperties: 1 additionalProperties: $ref: '#/components/schemas/SweepAxis' example: sampler: lhs seed: 487221 samples: 100 objective: sharpe params: rsiPeriod: from: 7 to: 28 step: 1 useTrendFilter: values: [true, false] SweepBaseConfig: type: object properties: initialFunding: type: number format: double exclusiveMinimum: 0 default: 100 feeRate: type: number format: double minimum: 0 default: 0.001 buyFeeRate: type: number format: double minimum: 0 sellFeeRate: type: number format: double minimum: 0 feeLeg: type: string enum: [RECEIVED, QUOTE, BASE] default: RECEIVED percentAmountToLock: type: number format: double exclusiveMinimum: 0 maximum: 100 ExecuteSweepRequest: type: object required: [strategyId, sweep] properties: strategyId: $ref: '#/components/schemas/strategyId' sweep: $ref: '#/components/schemas/SweepSpecRequest' baseConfig: $ref: '#/components/schemas/SweepBaseConfig' storeSignals: type: boolean default: false description: Store signals for every trial. Keep false for normal sweeps. shards: type: integer minimum: 0 description: Requested horizontal shard count; 0 or omitted selects automatically. default: 0 minTradeFloor: type: integer minimum: 0 default: 30 description: Trials below this trade count are flagged but remain in the results. walkForward: $ref: '#/components/schemas/WalkForwardRequest' equityCurve: $ref: '#/components/schemas/EquityCurveRequest' EquityCurveOptions: type: object description: >- Requested equity-curve transform, applied server-side in a fixed pipeline order: `resample` (point count) then `differential` (encoding) then `outMode` (JSON shape) — each stage assumes the previous one already ran. A server-side size guard can still force a smaller/deflated shape above its thresholds regardless of what is requested here — see `EquityCurveMeta` for what actually happened. properties: resample: type: integer minimum: 2 description: >- Downsample to at most this many points (extrema-preserving — the global max/min and the exact first/last point are always kept). Omit for no downsampling. differential: type: boolean default: false description: Delta-encode both fields from the second (post-resample) point onward. outMode: allOf: - $ref: '#/components/schemas/EquityCurveOutMode' default: ARRAY description: Requested JSON shape. EquityCurveRequest: allOf: - $ref: '#/components/schemas/EquityCurveOptions' - type: object properties: mode: type: string enum: [auto, topN, topPct, none] default: auto description: >- Which trials keep their per-point equity curve. `auto` retains curves only while the accumulated size stays within server limits; `topN`/`topPct` retain curves for the best-ranked trials explicitly; `none` retains no curves. n: type: integer minimum: 1 description: Trial count to retain when mode is topN. maxPct: type: number format: double exclusiveMinimum: 0 maximum: 100 description: Top percentage of trials to retain when mode is topPct. description: >- Selection (`mode`/`n`/`maxPct`) plus the transform preference (`resample`/`differential`/`outMode`) applied by `GET .../equityCurve` whenever ITS OWN query params are absent, for a curve this sweep retained. The transform half never affects retention or `sweepId` — a caller can always override it per-request at read time regardless of what was submitted here. WalkForwardRequest: type: object description: >- Opt in to walk-forward validation. Present, the sweep runs as F sequential folds and the result gains a `walkForward` section; absent, nothing about the sweep changes. Two requests that differ only in this block are two different sweeps and do not deduplicate against each other. required: [folds] properties: folds: type: integer minimum: 2 description: >- How many sequential optimize-then-score windows to run. Two is the minimum for a reason, and it is structural rather than a tuning choice: parameter drift is measured between consecutive fold winners, and a single fold — one train/test split with no sequence — has no consecutive pair to compare, so it would report the strongest possible stability having measured nothing. The upper bound is a server setting (12 by default) and is deliberately not pinned here, since a spec that hardcodes a tunable limit lies the day it is raised. Exceeding it, or exceeding the sweep budget once multiplied by the grid size, is a 400. inSamplePct: type: integer minimum: 10 maximum: 90 default: 66 description: >- Share of the session each fold spends optimizing; the remainder is where its winner is scored. Lower values leave more data to be scored on and, on short sessions, are also what lets the requested fold count tile the data at all. ExecuteSweepAccepted: type: object required: [sweepId, requestId, totalRuns, shards, seed, queued] properties: sweepId: type: string example: swp_95e47a7f0966ce11 requestId: type: string totalRuns: type: integer minimum: 1 shards: type: integer minimum: 1 seed: type: integer format: int64 minimum: -9007199254740991 maximum: 9007199254740991 description: Effective seed used to expand the sweep. queued: type: boolean description: False when an identical sweep already exists and was not enqueued again. walkForward: $ref: '#/components/schemas/WalkForwardAccepted' WalkForwardAccepted: type: object description: >- Echo of the accepted walk-forward configuration, present only when the submit carried one. `inSamplePct` is the resolved value, so a request that omitted it can see what it got. required: [folds, inSamplePct, totalRuns] properties: folds: type: integer inSamplePct: type: integer totalRuns: type: integer description: >- What this sweep actually costs, `folds × (grid size + 1)` — the in-sample runs for every fold plus each fold's one out-of-sample run. Deliberately distinct from the top-level `totalRuns`, which stays the size of the grid that was submitted. SweepProgress: type: object description: >- How far along a sweep is, and — when the sweep is still running — enough to tell a healthy one from a stuck one. The counts partition the shards (or, for a walk-forward sweep, the folds): every unit is either finished, failed, waiting to be retried, or not yet started. required: - done - total - aborted - shardCount - pendingShards - failedShards - retrying - notStarted properties: done: type: integer format: int64 total: type: integer aborted: type: integer format: int64 description: >- Individual runs that executed and aborted. A row-level count: a shard that fails before producing any rows leaves this at 0, which is why `failedShards` exists alongside it. shardCount: type: integer pendingShards: type: integer failedShards: type: integer format: int64 description: >- Shards (or folds) that failed and will not be retried. Distinct from `aborted`: this counts whole units that never reported, not runs that ran badly. retrying: type: integer description: >- Units whose last attempt failed on something transient — an I/O error, a worker that died mid-read — and which are queued to be attempted again. Not counted as failures, because they have not failed yet; a sweep with a non-zero value here is still expected to complete. notStarted: type: integer description: >- Units that have not reported anything yet. Covers both work still queued behind other work and work claimed by a worker that stopped before it began, which is why a sweep with a persistent value here and a rising `stalledSeconds` is worth looking at. stalledSeconds: type: integer format: int64 description: >- Seconds since anything last advanced. Omitted on a finished sweep, where it would only measure how long ago it finished, and on sweeps submitted before this field existed. etaSeconds: type: integer format: int64 description: >- Rough seconds remaining, extrapolated from the rate observed so far and assuming nothing else competes for workers. Runs conservative in practice — it has measured 2–5× long when a sweep spent part of its life waiting to be retried, since that wait dilutes the observed rate. **Omitted, never zero, when it cannot be computed**: a sweep with nothing finished yet has no rate to extrapolate from, and a zero would read as "about to finish". Excludes queue wait entirely; `retrying` and `stalledSeconds` are where that shows up. SweepRunRow: type: object required: - runIx - params - sharpe - sortino - pnl - pnlPct - cagr - maxDdPct - trades - winRate - belowTradeFloor - aborted - runtimeMs properties: runIx: type: integer minimum: 0 description: Deterministic zero-based expansion index, stable across shards and ranking. rank: type: integer minimum: 1 description: Present only in the `ranked` view. plateauScore: type: number format: double description: >- The objective of the worst run in this point's immediate neighbourhood — how well the region around it holds up, not how well it scored itself. Present only in the `ranked` view when plateau ranking applied. Always read together with `neighbourCount`. neighbourCount: type: integer minimum: 0 description: >- How many neighbouring parameter points backed the `plateauScore`. Zero means the point had no neighbours in the grid, so its score is unevidenced rather than confirmed — the value alone cannot be distinguished from a genuinely robust one. deflatedSharpe: type: number format: double minimum: 0 maximum: 1 description: >- Probability that this run's Sharpe reflects real edge rather than the best draw from however many parameter vectors were tried. Above ~0.95 the result survives the multiple-testing correction; near 0.5 or below it is indistinguishable from the best of a pile of coin flips. Absent on aborted runs, on sweeps with too few trials to establish any dispersion to deflate against, on runs with fewer than 3 period returns, and on a degenerate (near-constant) return series — all cases where the underlying statistic isn't meaningfully computable, rather than genuinely zero. A present value is the computed probability, however small. params: type: object additionalProperties: true sharpe: type: number format: double sortino: type: number format: double pnl: type: number format: double description: Absolute net PnL in the output currency. pnlPct: type: number format: double description: Same units as `pnlTotalPercent` on the single-run result — percent (0-100 scale). cagr: type: number format: double description: Same units as `cagr` on the single-run result — a ratio, not a percent. maxDdPct: type: number format: double description: Same units as `maxDrawdownPercent` on the single-run result — percent (0-100 scale). trades: type: integer format: int64 winRate: type: number format: double description: Same units as `winRate` on the single-run result — a fraction, 0.0-1.0 (a rate, not a percent). belowTradeFloor: type: boolean aborted: type: boolean runtimeMs: type: integer format: int64 equityCurve: allOf: - $ref: '#/components/schemas/EquityCurveResult' description: >- Present whenever this trial actually has a curve — which is any completed run of a sweep that requested curves at all (`equityCurve.mode` `topN`/`topPct`), not only the ranked winners. Absent, not null, when there is genuinely nothing: the run aborted, it made no trades, or the sweep never retained curves (`mode` `auto`/`none`). Selection decides how the curve travels, not whether it exists. A row may carry `url` alone, or `url` together with the points inline. **Read `points`/`equities` to tell whether the curve is inline — never the presence of this object, and never the absence of `url`.** Both mislead, and in opposite directions: this object is present on rows that carry only a pointer, and `url` stays present alongside an inline curve so a caller can still re-request a different transform. An inline curve is emitted on the materialisation view (`?order=natural`), where each row is read once; the ranked view keeps the pointer, being polled. When only a pointer is present, `GET` the `url` for the curve. Its own `meta` there is the real, possibly size-guarded outcome. This outer `meta` may be a declaration rather than a measurement — for a row whose curve was not promoted, the point count is derived from the trade count (one equity point per closed trade) instead of being read from the store, to keep a polled response from doing per-row work. SweepSensitivity: type: object description: >- Sensitivity aggregates over a sweep's stored rows. Marginals are always complete; heatmaps may be capped, in which case `heatmapsTruncated` is true. properties: sweepId: type: string status: type: string enum: [RUNNING, COMPLETED, PARTIAL, CANCELLED] objective: type: string enum: [sharpe, sortino, pnl, maxdd] rowsAnalysed: type: integer description: Rows available when this was computed. Grows while a sweep is still running. marginals: type: array items: $ref: '#/components/schemas/SweepMarginal' heatmaps: type: array items: $ref: '#/components/schemas/SweepHeatmap' heatmapsTruncated: type: boolean description: >- True when at least one two-parameter surface was left out to stay inside the response budget. Told explicitly because a silently short list would read as "these are all the interactions", which is the wrong thing to conclude from a sensitivity view. SweepMarginal: type: object description: One axis, with every other axis collapsed away. properties: param: type: string points: type: array items: $ref: '#/components/schemas/SweepMarginalPoint' SweepMarginalPoint: type: object description: >- How the objective behaved at one value of one axis. `best` and `mean` disagreeing is informative rather than noise: a high `best` with a poor `mean` marks a value that only works alongside particular settings of the other axes. properties: value: description: The axis value, as it appears in a run's parameters. count: type: integer description: Non-aborted runs that used this value. best: type: number format: double mean: type: number format: double worst: type: number format: double SweepHeatmap: type: object description: The surface for one pair of axes, with all others collapsed away. properties: paramA: type: string paramB: type: string cells: type: array items: $ref: '#/components/schemas/SweepHeatmapCell' SweepHeatmapCell: type: object properties: valueA: {} valueB: {} count: type: integer best: type: number format: double mean: type: number format: double ExecuteSweepResult: type: object required: - sweepId - status - objective - order - progress - leaderboardSize - truncated - leaderboard - state properties: sweepId: type: string status: type: string enum: [RUNNING, COMPLETED, PARTIAL, CANCELLED] description: >- The sweep's own status vocabulary — not the same set `state.status` below uses. See `state` for why. objective: type: string enum: [sharpe, sortino, pnl, maxdd] order: type: string enum: [ranked, natural] ranking: type: string enum: [plateau, raw] description: >- Which ordering was actually applied, which is not always the one requested: a sweep with no stored parameter grid cannot be plateau-ranked and falls back to `raw`. Always `raw` when `order=natural`. pbo: type: number format: double minimum: 0 maximum: 1 description: >- Probability of backtest overfitting for the sweep as a whole, by combinatorially symmetric cross-validation: how often the configuration that won in-sample lands below median out-of-sample. Above ~0.5 the sweep is selecting noise, whatever its top row says. Computed once when the last shard finishes, so it is absent while the sweep is still running and on sweeps too small for the statistic to mean anything. pboSplits: type: integer description: How many train/test splits the `pbo` figure was averaged over. failReason: type: string description: >- Why the sweep produced less than it should have — the cause reported by the **first** shard to fail, not a list. It is what turns an inscrutable empty leaderboard into an answer: a sweep can come back `PARTIAL` with `done: 0` because the strategy could not be loaded at all, and without this the response says only that nothing finished. First failure wins and later ones are not recorded, so on a sweep where several shards failed for different reasons this names one of them rather than all. Absent when no shard reported a cause, which is the normal case for a healthy sweep — read it together with `progress.failedShards` rather than as a count of anything. example: "Failed to load/configure strategy" progress: $ref: '#/components/schemas/SweepProgress' leaderboardSize: type: integer description: Total result rows currently available. truncated: type: boolean description: True only when the ranked view exceeds its display limit. leaderboard: type: array items: $ref: '#/components/schemas/SweepRunRow' walkForward: $ref: '#/components/schemas/WalkForwardResult' state: allOf: - $ref: '#/components/schemas/JobState' description: >- The same `JobState` shape a single-execute `BacktestJobResult` carries — not a sweep-specific lookalike, the actual type, so field names and timestamp formatting match exactly. `state.status` uses `JobState`'s own vocabulary (`New`/`Started`/`Completed`/ `Aborted`/`Failed`), mapped from the sweep's `status` field above rather than copying it: `PARTIAL` and `CANCELLED` both map to `Aborted`, because a sweep's `PARTIAL` is already terminal (some shards finished, some failed, nothing more is coming) unlike a single job's non-terminal `Partial`, which has no equivalent here at all. `state.completed` is real ticks processed on a plain sweep. On a `walkForward` sweep it is currently always `0` — the walk-forward fold runner was not wired to count ticks when this shipped, unlike the plain shard path. `state.size` is an upfront estimate — range x the prepare's target cadence, set before any data is even loaded, not a query against loaded data — on a single execute and a plain sweep alike. A plain sweep's value is the sum of every shard's own `size` (each shard's per-run estimate x its own vector slice), the same additive shape `state.completed` already used above. `0` means the prepare context behind the job predates this field (a job whose prepare ran before the estimate existed) — never a guessed cadence standing in for a real one. On a `walkForward` sweep, `state.size` is still always `0`: fold runs were out of scope for the estimate the same way they are for `state.completed` above — a fold's `size` is simply never set, so the sum stays honest at `0` rather than needing a special case. WalkForwardResult: type: object description: >- Present only on a sweep submitted with `walkForward`, and present from acceptance onward — its presence, not its contents, is what identifies a walk-forward sweep. `completedFolds` is 0 while the first fold is still running. required: [folds, completedFolds, results] properties: folds: type: integer description: Folds requested at submit. inSamplePct: type: integer description: Resolved in-sample share each fold optimized on. completedFolds: type: integer description: Folds that have finished and reported a winner. paramDrift: type: number format: double minimum: 0 description: >- Mean normalized lattice distance between consecutive fold winners. Low is good: winners that stay in a tight band fold after fold are evidence the parameter means something, while winners that jump across the grid every time are the sweep re-fitting noise, and that backtest will not survive contact with live data. **Absent is not zero** — the field is omitted whenever the figure could not be computed (fewer than two folds finished, no stored grid to place winners on), because zero is itself a meaningful reading here and a placeholder would be indistinguishable from perfect stability. results: type: array description: One entry per completed fold, oldest first. items: $ref: '#/components/schemas/WalkForwardFold' WalkForwardFold: type: object description: >- What one fold concluded. The out-of-sample row is the answer; the in-sample figure is only there to be compared against it, since any grid produces a flattering in-sample winner — that is what optimizing does. The gap between them is the whole reading. required: - foldIx - inSampleFrom - inSampleTo - outOfSampleTo - params - inSampleSharpe - outOfSample - vectorsRun properties: foldIx: type: integer description: Position in the walk-forward sequence, oldest first. inSampleFrom: type: integer description: First index of the optimization window, into the prepared session. inSampleTo: type: integer description: End of the optimization window, exclusive — and where scoring begins. outOfSampleTo: type: integer description: End of the scoring window, exclusive. params: type: object additionalProperties: true description: The parameter vector that won this fold's optimization window. inSampleSharpe: type: number format: double description: How that winner scored on the window it was chosen on. outOfSample: $ref: '#/components/schemas/SweepRunRow' vectorsRun: type: integer description: Vectors this fold evaluated in-sample before picking its winner. AcceptedJob: type: object description: | Response returned by async endpoints (`202 Accepted`). The `jobId` is deterministic for the same input parameters — repeated calls with identical params return the same id. required: - jobId properties: jobId: type: string description: >- Unique job identifier; use this to poll for completion. For a sweep, pass this same value as the `requestId` path parameter to `executeSweep` — same identifier, different name at that call site. example: 13RBLGQlPnfDjO6wyKSX8i example: jobId: "13RBLGQlPnfDjO6wyKSX8i" BacktestJobResult: type: object description: Backtest job result. required: - results - state properties: results: $ref: '#/components/schemas/ResultMap' state: $ref: '#/components/schemas/JobState' ResultMap: type: object description: >- Execution result map. Always includes core fields (hostName, iops, strategyId, instrument). Yield metrics (pnlTotal, pnlTotalPercent, totalTrades, winRate, equityCurve, etc.) are present when the strategy emitted at least one trade. When signal storage is enabled, includes signal fields described below. `notices` carries what the run had to say about itself, and is absent when it had nothing. required: - strategyId - instrument properties: hostName: type: string description: Identifier of the worker that executed the strategy. Useful when reporting issues so support can correlate with logs. example: "executor10" iops: type: number format: double description: Instrument operations per second throughput during execution example: 123956.53 strategyId: type: string description: | **Not the `strategyId` you compiled with** — this is the execution context id, `strategy::`. The compiled strategy's id is the last `:`-separated segment; that, not this whole string, is what `GET /strategy/{strategyId}` takes. Take the segment after the last `:` rather than counting from the front: the shape has changed once already and callers that indexed a fixed position broke on it. example: "strategy:00000000-0000-0000-0000-000000000000:2iyvtenlzh9dabqtxn7nbv" instrument: type: string description: The instrument (currency pair) that was backtested example: "BTC/USDT" notices: type: array description: | Diagnostics the engine raised over this run, each with `provenance: execute`. **Absent means nothing was raised.** This is the one surface where silence is a real answer: the run happened, over your data, start to finish, and the engine found nothing worth saying. That is not true of the compile path, where an empty list only means a short synthetic series reached nothing — see `GET /strategy/{strategyId}`. Notices are raised on failed and aborted runs too, and those are the ones most worth reading: a run that produced no trades often did so for a reason stated here. items: $ref: '#/components/schemas/Notice' noticesTruncated: type: integer description: >- How many notices were dropped past the cap of 50. Absent when none were. A large value usually means one fault repeating per instrument or per parameter vector rather than 50 distinct problems. example: 3 pnlTotal: type: number format: double description: Total profit and loss in the output currency example: 42.75 pnlTotalPercent: type: number format: double description: Total PnL as a percentage of the initial capital (`backtestFunding`). Zero when `backtestFunding` is 0. example: 42.75 totalTrades: type: integer format: int64 description: Total number of trades executed by the strategy example: 156 winRate: type: number format: double description: >- Fraction of profitable trades, 0.0-1.0 (a rate, not a percent — multiply by 100 to display as one). Zero when `totalTrades` is 0. example: 0.5833 sharpeRatio: type: number format: double description: Risk-adjusted return ratio (mean return / standard deviation of returns) example: 1.245 sortinoRatio: type: number format: double description: Downside risk-adjusted return ratio (mean return / downside deviation) example: 1.872 cagr: type: number format: double description: Compound Annual Growth Rate (eg. 0.15 for 15%) example: 0.1534 maxDrawdown: type: number format: double description: Maximum absolute drawdown in the output currency example: 12.50 maxDrawdownPercent: type: number format: double description: Maximum percentage drawdown from peak equity example: 8.75 equityCurve: allOf: - $ref: '#/components/schemas/EquityCurveResult' description: >- Equity curve over the backtest. `points[0]` (or `timestamps[0]`/`equities[0]` in `SHORT` mode) is an anchor at the backtest `from` with `initialCapital`; the remaining points are one sample per emitted yield, in order. Use it to plot the strategy's running equity without re-deriving it from the yield history. Always inline for a plain backtest today — never a pointer (`url`); that shape exists only for a sweep row's top-N winners. example: points: - timestamp: 1700000000000 equity: 100.0 - timestamp: 1700000060000 equity: 110.5 - timestamp: 1700000120000 equity: 90.25 meta: inputPointCount: 3 outputPointCount: 3 resampled: false differential: false outMode: ARRAY params: type: object additionalProperties: $ref: '#/components/schemas/ScalarStrategyParamValue' description: >- The strategy properties this run was given, echoed back as sent. Absent when the request carried none, so its presence is what distinguishes a parameterised run from one at the declared defaults — a stored result cannot otherwise say which vector produced it, and this endpoint is meant to be called repeatedly over one prepare. example: ema.fast.period: 9 ema.slow.period: 21 signalCount: type: integer description: Number of signals emitted during strategy execution example: 100000 signalsId: type: string description: Storage key for the signals file. Treat as opaque; use signalsUrl to download. example: "00000000-0000-0000-0000-000000000000/exec/binance/3vsndwikcuaatjmb83fjtl" signalsUrl: type: string format: uri description: HTTPS URL to download the signals Parquet file. Use signalsUpload to know when it's ready. example: "https://storage.qtsurfer.com/00000000-0000-0000-0000-000000000000/exec/binance/3vsndwikcuaatjmb83fjtl.parquet" signalsUpload: type: string enum: [Done, Failed, Skipped] description: >- Upload status. Done = signal file is available at signalsUrl. Failed = upload error (see signalsUploadReason). Skipped = no signals emitted. example: "Done" signalsUploadedAt: type: string format: date-time description: ISO 8601 timestamp of when the upload completed. Only present when signalsUpload is Done. example: "2026-03-18T13:21:48.170Z" signalsUploadReason: type: string description: Human-readable reason when signalsUpload is Failed or Skipped. example: "signal file generation failed" EquityPoint: type: object description: Single sample of the running equity at a yield event. required: - timestamp - equity properties: timestamp: type: integer format: int64 description: >- Epoch milliseconds. The first point in an equity curve is anchored at the backtest `from`; subsequent points carry the timestamp of each emitted yield. example: 1700000000000 equity: type: number format: double description: Running equity at this point (`initialCapital + cumulativePnl`). example: 110.5 EquityCurveOutMode: type: string enum: [ARRAY, SHORT] description: >- JSON shape for an equity curve's points. `ARRAY` is `[{timestamp, equity}, ...]`; `SHORT` is `{timestamps: [...], equities: [...]}` (parallel arrays, no repeated key text). The one schema shared by every place `outMode` appears, request or response, so the two cannot drift to different value sets. EquityCurveMeta: type: object description: >- What the transform pipeline actually did, computed from the observed outcome — never a copy of what was requested. Lets a caller detect a forced or no-op transform (e.g. a `resample` ceiling already above the curve's size is a legal no-op, reported honestly as `resampled: false`). required: - inputPointCount - outputPointCount - resampled - differential - outMode properties: inputPointCount: type: integer description: Size of the curve the transform pipeline received. example: 100000 outputPointCount: type: integer description: Size after the full pipeline (resample, then differential, then outMode). example: 100 resampled: type: boolean description: True only if the resample stage actually changed the point count. differential: type: boolean description: >- True only if delta-encoding actually ran. Requesting it on a curve of 0 or 1 points has nothing to encode, so it does not run even if asked. outMode: allOf: - $ref: '#/components/schemas/EquityCurveOutMode' description: >- The actual JSON shape served. Present even when the points themselves are not (a pointer curve, `EquityCurveResult.url`) — a caller resolving that URL separately still needs to know how to parse what it gets back before fetching it. May override an explicit request above a server-side size threshold; this field, not the request, is the source of truth for what shape actually came back. example: ARRAY EquityCurveResult: type: object description: >- An equity curve, shaped per `meta.outMode`: `points` when `ARRAY`, `timestamps` + `equities` (parallel arrays) when `SHORT`. Used identically wherever a curve is returned — a plain backtest's inline `equityCurve` and a sweep row's `equityCurve` are the same type. `url` is present *instead of* any points when the curve is served by pointer rather than inline (a sweep row's top-N winners only): `GET` it separately to fetch this exact same shape with the points populated. required: - meta properties: meta: $ref: '#/components/schemas/EquityCurveMeta' points: type: array description: Present when `meta.outMode` is `ARRAY` and the curve is inline (not a pointer). items: $ref: '#/components/schemas/EquityPoint' timestamps: type: array description: Present when `meta.outMode` is `SHORT` and the curve is inline (not a pointer). items: type: integer format: int64 equities: type: array description: >- Present when `meta.outMode` is `SHORT` and the curve is inline (not a pointer), parallel to `timestamps` (same index, same point). items: type: number format: double url: type: string description: >- Present only for a sweep row's pointer curve. `GET` this to fetch the curve itself, in this exact `{points|timestamps+equities, meta}` shape — `meta` there is the real, possibly size-guarded outcome; this outer `meta` is a raw, untransformed preview from the moment the sweep selected this trial's curve, and the two can legitimately differ. example: "/v1/backtest/binance/ticker/executeSweep/req-1/swp_test/runs/3/equityCurve" strategyId: description: | Unique identifier for a compiled strategy, derived from the source itself: the same code always yields the same id, for every caller. How much formatting the id ignores depends on the language — see `POST /strategy` for exactly which rewrites preserve it and which do not. type: string example: 6bsh31ikwkuivhtgcoa6s4 DeclaredProperty: type: object description: | One property name `POST /strategy` could establish without constructing the strategy — either declared with `@StrategyProperty` on the compiled source, or one of the small set of base properties every strategy carries (`amnt`, `enabled`, `multiEntry`, ...). **Best-effort, not exhaustive.** A property registered through an attached risk/backtest config needs a live instance to discover and is not listed here. Use this to catch a typo'd sweep key before submitting, not as the definitive list of what a sweep will accept — a name absent from this list may still be valid. required: [name] properties: name: type: string description: The key a sweep or execute param map uses for this property. example: rsi.period description: type: string description: Human-readable label, as declared. example: RSI period defaultValue: type: string description: | The declared default, as a string, if one was given. Absent, not null, when none was declared. example: '14' reflected: type: boolean description: | Whether a value for this key is injected into the strategy's field (`true`) or only available through the property map (`false`). example: true min: type: number format: double description: Suggested sweep/range minimum, if declared. Advisory only, never validated. example: 2 max: type: number format: double description: Suggested sweep/range maximum, if declared. Advisory only, never validated. example: 50 step: type: number format: double description: Suggested sweep/range step, if declared. Advisory only, never validated. example: 1 Notice: type: object description: | A diagnostic the engine raised while the strategy ran. Advisory: it describes something worth knowing about how the strategy is wired, not necessarily an error. required: [level, code, message] properties: level: type: string description: Severity as the engine classified it. example: WARN code: type: string description: Stable identifier for the kind of finding; safe to match on. example: indicator.bar-data-on-ticker-path message: type: string description: Human-readable explanation. example: Indicator requires bar data but is on the ticker path provenance: type: string enum: [execute, compile-dry-run] description: | Where it came from, which matters because the two silences differ: an empty list from a real run (`execute`) is a clean bill of health, while an empty list from `compile-dry-run` is only a lower bound over a bounded synthetic series. example: compile-dry-run StrategySummary: type: object description: | One entry from `GET /strategies` — the same provenance a full `StrategyState` carries (`compiledAt`, `requiredSources`), without its validation state, so listing stays cheap regardless of how many strategies you have registered. Check a specific strategy's validation with `GET /strategy/{strategyId}`. required: [strategyId] properties: strategyId: $ref: '#/components/schemas/strategyId' compiledAt: type: string format: date-time description: When the live compilation was produced. requiredSources: type: array description: | The market data this strategy needs. Absent, not empty, when it could not be established without constructing the strategy. items: type: string StrategyState: type: object description: | What is known about a registered strategy: that it compiled, and what validating it found. **`validation: passed` does not mean the strategy is correct.** It means the class loaded and survived the first event of a short synthetic run — a floor, not a guarantee. When `dryRunIncomplete` is true it is a lower floor still, because the run did not finish. required: [strategyId, validation] properties: strategyId: $ref: '#/components/schemas/strategyId' validation: type: string enum: [not_validated, pending, passed, failed] description: | * `not_validated` — registered, never checked. `POST /strategy/{strategyId}/validate` checks it. * `pending` — a check was asked for and has not answered yet. * `passed` — the class loaded and survived its first event. * `failed` — it did not; `detail` says how. example: passed compiledAt: type: string format: date-time description: When the live compilation was produced. requiredSources: type: array description: | The market data a strategy needs, read off the compiled class rather than off anything you sent — `TickerStrategy`, `KlineStrategy` and `FundingRateStrategy` each declare one, and a `MultiSourceStrategy` declares a set. **Absent is not "needs nothing".** A strategy always needs market data, so an absent field never means an empty requirement — it means the platform could not establish the answer without constructing your strategy, which it will not do to fill in a field. That happens for a `MultiSourceStrategy`, for a class that overrides `getMarketDataSource()`, and for anything registered before this field existed; re-registering the source fills it in. items: type: string enum: [Ticker, KLine, FundingRate] example: [Ticker] validatedAt: type: string format: date-time description: When the verdict was recorded. Absent until there is one. detail: type: string description: | Why validation failed, or why a queued check has not reported. Present on `failed`, and alongside `validationStalled`. notices: type: array description: | What the run surfaced. An empty or absent list is not a clean bill of health when `dryRunIncomplete` is true — see that field. items: $ref: '#/components/schemas/Notice' noticesTruncated: type: integer description: How many notices were dropped past the cap. Absent when none were. example: 3 dryRunIncomplete: type: boolean description: | The check did not finish its budget — it ran out of time, was refused because the platform was already holding too many unfinishable runs, or hit a failure attributable to the synthetic instrument rather than to your strategy. The verdict stands as far as it went; it simply reached less than a full run would. validationStalled: type: boolean description: | A queued check has not reported for far longer than one takes. Nothing is disproved about the strategy — the check has not run. Stop waiting and re-request it later. _links: $ref: '#/components/schemas/StrategyLinks' example: strategyId: 6bsh31ikwkuivhtgcoa6s4 validation: passed compiledAt: '2026-08-04T16:23:04Z' requiredSources: [Ticker] validatedAt: '2026-08-04T16:24:11Z' notices: - level: WARN code: indicator.bar-data-on-ticker-path message: Indicator requires bar data but is on the ticker path provenance: compile-dry-run _links: code: href: /v1/strategy/6bsh31ikwkuivhtgcoa6s4/code Dataset: type: object description: | A dataset's own metadata — not its data. `currentVersionId` is what a prepare against `exchangeId: user` reads by default; see `DatasetVersion` for what a version carries. `from`/`to`/`cadence`/`timestampUnit`/`bytes`/`rows`/`gaps`/`largestGapSteps` mirror that current version's own discovered range, cadence, timestamp unit and metrics, so you don't need a second call to `GET /datasets/{datasetId}/uploads/{uploadId}` just to see what a dataset covers. required: [datasetId, name, type, instrument, createdAt, status] properties: datasetId: type: string description: Opaque id, returned by `POST /datasets`. example: "ds_3f9a1c2e7b0d4a5f" name: type: string description: Unique among your datasets. example: "My BTC ticks" type: type: string enum: [ticker, klines] description: | `ticker` for an upload or a `dex` import with no `cadence` requested (native per-trade data). `klines` for a `dex` import that requested a candle `cadence` — pre-aggregated bars rather than raw ticks. Purely informational; both shapes are read the same way. example: "ticker" instrument: $ref: '#/components/schemas/Instrument' createdAt: type: string format: date-time description: When the dataset was created. example: "2026-08-20T09:00:00Z" currentVersionId: type: string description: | The id of the most recently finalized, successfully ingested version. Absent until at least one upload has finished ingesting. example: "dsv_8e2b4f19c6a03d7e" updatedAt: type: string format: date-time description: When `currentVersionId` last changed. Absent until it has a value. example: "2026-08-20T09:04:12Z" from: type: string format: date-time description: | Start of `currentVersionId`'s own data range, as discovered at ingest time. Absent until a version exists. example: "2026-03-01T00:00:00Z" to: type: string format: date-time description: | End of `currentVersionId`'s own data range, as discovered at ingest time. Absent until a version exists. example: "2026-03-08T00:00:00Z" cadence: type: string description: | `currentVersionId`'s own discovered cadence — a fixed grid (e.g. `1s`, `1m`, `1h`) or `rt` (see `DatasetVersion.cadence`). Absent until a version exists. example: "1m" timestampUnit: type: string enum: [iso, s, ms, us] description: | `currentVersionId`'s own timestamp unit (see `DatasetVersion.timestampUnit`) — decode the `timestamp` column of `dataUrl`'s file accordingly. Present only when `status` is `ready`. example: "iso" status: type: string enum: [ready, failed, pending] description: | * `ready` — `currentVersionId` is set; `from`/`to`/`cadence`/`bytes`/`rows`/`gaps`/ `largestGapSteps` describe it. * `failed` — the most recent upload/import attempt failed. `currentVersionId` and the fields above are absent — there is nothing to read yet. See `error`. * `pending` — nothing has ever been attempted (just created, or an upload was never finalized). example: "ready" bytes: type: integer description: | Size of `currentVersionId`'s own stored file. Present only when `status` is `ready` — see `DatasetVersion.bytes` for what it measures exactly. example: 4831022 rows: type: integer description: >- `currentVersionId`'s own row count. Present only when `status` is `ready`. example: 86400 gaps: type: integer description: >- `currentVersionId`'s own gap count at its discovered cadence. Present only when `status` is `ready`. example: 0 largestGapSteps: type: integer description: >- `currentVersionId`'s own largest gap, in units of its discovered cadence step. Present only when `status` is `ready`. example: 0 error: type: string description: | A human-readable reason the most recent upload/import attempt failed. Present only when `status` is `failed`. example: "line 3: column 'close' is not a number: not-a-number" DatasetWithLinks: description: A `Dataset` plus a self link. Returned by `GET /datasets/{datasetId}`. allOf: - $ref: '#/components/schemas/Dataset' - type: object properties: dataUrl: type: string description: | Presigned GET URL to the current version's stored file — see `dataFormat` for which format it's actually in. Present only once the current version's status is `ready`. Long-lived (day-scale, not permanent): a DuckDB-WASM/`lastra-ts`-style reader issues HTTP range requests against it lazily over an extended viewing session, not in one shot like a browser upload. example: "https://storage.qtsurfer.com/00000000-.../ds_3f9a1c2e7b0d4a5f/dsv_8e2b4f19c6a03d7e/ticker_BTC_USDT_1700000000000_1700086400000_1m.lastra?X-Amz-..." dataFormat: type: string enum: [lastra, parquet] description: | Which format `dataUrl` is actually in — check this rather than assuming it matches how you uploaded it. `lastra` — our native columnar format — for a CSV (or gzip/zip of one) upload, always converted on ingest, or for a lastra upload, stored as-is (the value alone doesn't tell you which). `parquet` for a parquet upload, also stored as-is today. example: "lastra" _links: type: object properties: self: type: object properties: href: type: string example: "/v1/datasets/ds_3f9a1c2e7b0d4a5f" DatasetUploadTarget: description: A presigned destination for uploading a raw dataset file directly to storage. type: object required: [url, expiresInMinutes] properties: url: type: string description: | Presigned URL. `PUT` the file here directly — the CSV or parquet itself, or a `.gz`/`.zip` of it (see `createDataset`'s own description) — no `Authorization` header, no other API credentials. example: "https://storage.qtsurfer.com/uploads/00000000-.../up_1a2b3c4d5e6f7a8b/raw.csv?X-Amz-..." expiresInMinutes: type: integer description: How long `url` stays valid. example: 15 DatasetUploadSession: description: | An upload session — an id plus the presigned URL to PUT the raw file to. Returned both by `POST /datasets` (as part of the new dataset) and by `POST /datasets/{datasetId}/uploads` (on its own, for an existing one). type: object required: [uploadId, upload] properties: uploadId: type: string description: | Identifies this upload session. Pass to `POST /datasets/{datasetId}/uploads/{uploadId}/finalize` once the PUT completes. example: "up_1a2b3c4d5e6f7a8b" upload: $ref: '#/components/schemas/DatasetUploadTarget' DatasetCreated: description: | The metadata available immediately after creating a dataset, plus its first upload session — the presigned URL to PUT the file to. Version-derived fields such as `createdAt`, `currentVersionId`, range, and cadence are available from `GET /datasets/{datasetId}` after the relevant lifecycle stages, not in this creation response. allOf: - type: object required: [datasetId, name, type, instrument] properties: datasetId: type: string description: Opaque id of the newly created dataset. example: "ds_3f9a1c2e7b0d4a5f" name: type: string description: Unique name of the newly created dataset. example: "My BTC ticks" type: type: string enum: [ticker] description: Always `ticker` in v1. example: "ticker" instrument: $ref: '#/components/schemas/Instrument' - $ref: '#/components/schemas/DatasetUploadSession' DatasetVersion: type: object description: | One successfully ingested upload. Cadence and timestamp unit are discovered from the file, not declared by the caller. required: [datasetId] properties: datasetId: type: string example: "ds_3f9a1c2e7b0d4a5f" id: type: string description: The version id. Pass as `datasetVersionId` on `POST .../prepare` to pin it. example: "dsv_8e2b4f19c6a03d7e" bytes: type: integer description: >- Size of the stored file `dataUrl` points at — a converted `lastra` for a CSV/gzip/zip upload, or the parquet file itself, unconverted, for a parquet upload. Not the size of the bytes originally PUT to storage; see `dataFormat`. example: 4831022 rows: type: integer description: Number of data rows. example: 86400 cadence: type: string description: | The cadence discovered from the data's own timestamps. Either a fixed grid — `1s`, `5s`, `15s`, `1m`, `5m`, `15m`, `30m`, `1h`, `4h`, `1d` — when at least half the intervals between consecutive rows fall on that step (small clock jitter tolerated), or `rt`: native data at the rate it was captured, each row at its own timestamp with no fixed step — per-trade on-chain swaps, block-spaced or sub-second ticks, irregular intervals. An `rt` dataset can be resampled to any fixed cadence at prepare time. example: "1s" timestampUnit: type: string enum: [iso, s, ms, us] description: | The unit the `timestamp` column was uploaded in — ISO-8601, or the epoch band its numeric values fell in (seconds, millis, or micros). example: "iso" gaps: type: integer description: Number of gaps at the discovered cadence. Always `0` for `rt`. example: 0 largestGapSteps: type: integer description: The largest gap, in units of the discovered cadence step. Always `0` for `rt`. example: 0 dataUrl: type: string description: | Presigned GET URL to the stored file — see `dataFormat` for which format it's in. Present once the version is `ready`. Long-lived (day-scale, not permanent): a DuckDB-WASM/`lastra-ts`-style reader issues HTTP range requests against it lazily over an extended viewing session, not in one shot like a browser upload. example: "https://storage.qtsurfer.com/00000000-.../ds_3f9a1c2e7b0d4a5f/dsv_8e2b4f19c6a03d7e/ticker_BTC_USDT_1700000000000_1700086400000_1m.lastra?X-Amz-..." dataFormat: type: string enum: [lastra, parquet] description: | Which format `dataUrl` is actually in — check this rather than assuming it matches how you uploaded it. `lastra` — our native columnar format — for a CSV (or gzip/zip of one) upload, always converted on ingest, or for a lastra upload, stored as-is (the value alone doesn't tell you which). `parquet` for a parquet upload, also stored as-is today. example: "lastra" DatasetUploadState: type: object description: | Progress of one upload, from staged through ingest. Durably recorded once a version exists, so `ready`/`failed` are permanent answers; `uploading`/`ingesting` reflect in-flight state that can itself age out — see the `404` case on `GET .../uploads/{uploadId}`. required: [uploadId, status] properties: uploadId: type: string example: "up_1a2b3c4d5e6f7a8b" status: type: string enum: [uploading, ingesting, ready, failed] description: | * `uploading` — the file was PUT to the presigned URL, but `finalize` has not been called yet. * `ingesting` — `finalize` was called; the worker is parsing and validating the file. * `ready` — ingested successfully. `version` carries the result. * `failed` — ingest rejected the file (e.g. bad CSV contract, mixed timestamp units, a `.zip` with no file inside or more than one). example: "ready" jobId: type: string description: The ingest job id, while `status` is `ingesting`. error: type: string description: | A human-readable reason, present when `status` is `failed` (e.g. bad CSV contract, mixed timestamp units, a `.zip` with no file inside or more than one). Durably recorded alongside the failure itself, so it stays available however long after the fact you poll — not tied to how recently the failure happened. example: "line 3: column 'close' is not a number: not-a-number" version: allOf: - $ref: '#/components/schemas/DatasetVersion' description: Present when `status` is `ready` or `failed`. DatasetImportRequest: type: object description: | `POST /datasets/imports`'s request body. A common block plus one type-specific block, selected by `type` — `dex` is the only value today. required: [name, instrument, from, to, type] properties: name: type: string description: A name unique among your datasets. `409` if already taken. example: "weth-usdc-week" instrument: $ref: '#/components/schemas/Instrument' from: type: string format: date-time description: Start of the range to fetch, inclusive. Must be before `to`. example: "2026-08-01T00:00:00Z" to: type: string format: date-time description: | End of the range to fetch, exclusive. The total span is capped by your tier — a request wider than that ceiling is `400`, regardless of source type. example: "2026-08-08T00:00:00Z" cadence: type: string enum: ["1s", "1m", "5m"] description: | Optional. Omitted/blank keeps native per-trade event cadence — each swap at its own timestamp, so the resulting version's `cadence` is `rt` unless the swaps happen to sit on a fixed grid (see `DatasetVersion.cadence`). Set to `1s`, `1m` or `5m` instead to get pre-aggregated candles at that width rather than raw trades (the resulting dataset's `type` becomes `klines`); any other value is `400`. Not every network supports every cadence — an unsupported combination fails asynchronously, not at request time (see `DatasetImportState.error`). type: type: string enum: [dex] description: The source to fetch from. `dex` is the only value today. example: "dex" dex: $ref: '#/components/schemas/DatasetImportDexRequest' DatasetImportDexRequest: type: object description: | The `dex` source's own fields — required when `type` is `dex`. `id`/`version` are required for a plain (native-cadence) import; both are ignored if the top-level `cadence` requested pre-aggregated candles instead, since that path needs neither a protocol nor a version distinction. required: [network, contract] properties: network: type: string enum: [ethereum, robinhood] description: Which chain the pool/pair lives on. example: "ethereum" id: type: string enum: [uniswap] description: | Which on-chain DEX protocol `contract` implements. Required unless the top-level `cadence` requested pre-aggregated candles, in which case it's ignored. example: "uniswap" version: type: string enum: [v2, v3] description: | Uniswap version the pool/pair contract implements. Required unless the top-level `cadence` requested pre-aggregated candles, in which case it's ignored. example: "v3" contract: type: string description: The pool (v3) or pair (v2) contract address. example: "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640" factory: type: string description: | The factory that deployed `contract`. Optional — when omitted, it is discovered on-chain from `contract` itself at fetch time. Supply it explicitly only if you already know it, or the pool/pair belongs to a factory other than the canonical one for `network`/`version`. Either way, the pool/pair is validated against whichever factory is used before anything is fetched — a wrong or unrelated factory fails the import rather than silently fetching from the wrong pool. Ignored if the top-level `cadence` requested pre-aggregated candles. example: network: "ethereum" id: "uniswap" version: "v3" contract: "0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640" DatasetImportCreated: type: object description: The response to `POST /datasets/imports` — the dataset now exists, and its fetch has started. required: [datasetId, importId, jobId, status] properties: datasetId: type: string description: Opaque id of the newly created dataset — same id space as `POST /datasets`. example: "ds_3f9a1c2e7b0d4a5f" importId: type: string description: | Identifies this import. Pass to `GET /datasets/{datasetId}/imports/{importId}` to poll it — there is no separate "finalize" step the way an upload has. example: "imp_01j9z1x2y3z4a5b6c7d8e9f0g1" jobId: type: string description: The fetch/ingest job id. example: "dataset-import:00000000-.../ds_3f9a1c2e7b0d4a5f:imp_01j9z1x2y3z4a5b6c7d8e9f0g1" status: type: string enum: [fetching] description: Always `fetching` in this response — the fetch has only just started. example: "fetching" DatasetImportState: type: object description: | Progress of one import, from fetching through ingest. `fetching` is the one status only an import ever reports — an upload's file already exists by the time you can poll it; an import's doesn't, until this source finishes fetching it. required: [importId, status] properties: importId: type: string example: "imp_01j9z1x2y3z4a5b6c7d8e9f0g1" status: type: string enum: [fetching, ingesting, ready, failed] description: | * `fetching` — reading from the source; nothing staged yet. * `ingesting` — fetch complete, staged, and re-entered the same ingest chain an upload uses; the worker is parsing and validating it. * `ready` — ingested successfully. `version` carries the result. * `failed` — the fetch or the ingest that followed it was rejected. `error` names why. example: "ready" jobId: type: string description: The fetch/ingest job id, while `status` is `fetching` or `ingesting`. error: type: string description: | A human-readable reason, present when `status` is `failed` — an unresolvable pool/pair, no data in the requested range, a range older than the configured source retains, the fetch exceeding your tier's time ceiling, or any of `DatasetUploadState.error`'s own ingest-side reasons once fetching hands off to it. Durably recorded, same as on the upload path. example: "Import exceeded the tier's 12 minute ceiling" version: allOf: - $ref: '#/components/schemas/DatasetVersion' description: Present when `status` is `ready` or `failed`. AuthTokenResponse: type: object required: [access_token, token_type, expires_in, tier] properties: access_token: type: string description: "Short-lived HS256 JWT. Send as `Authorization: Bearer ` on all other endpoints." token_type: type: string enum: [Bearer] description: Always `Bearer`. expires_in: type: integer description: Seconds until the JWT expires (typically 3600). example: 3600 scopes: type: array description: Scopes granted to this token. Reserved for future use; currently always empty. items: type: string example: [] tier: type: string enum: [free, basic, pro, elite] description: Subscription tier this token was issued for. Drives rate limits and feature flags on downstream endpoints. example: free AuthTokenError: type: object required: [code, message] description: Error envelope returned by `POST /auth/token` when the API key is rejected. properties: code: type: string enum: [invalid_apikey, apikey_revoked, apikey_expired] description: Machine-readable error reason. message: type: string description: Human-readable description of the failure. securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT description: JWT bearer token. The `sub` claim must contain the user identifier. apiKeyAuth: type: apiKey in: header name: X-API-Key description: | Long-lived API key, issued via the web app. Used **only** against `POST /auth/token` to exchange for a short-lived JWT. All other endpoints expect the resulting JWT via `bearerAuth`. tags: - name: Auth description: |- The **auth** endpoint exchanges a long-lived API key for a short-lived JWT used by every other endpoint. Issue an API key via the web app, then call `POST /auth/token` at the start of each session (and on `401` responses) to obtain a fresh JWT. **Key functionalities**: - Exchange an API key for a JWT carrying the caller's subscription tier. - Refresh the JWT before expiry without re-using the API key against any other endpoint. - name: Account description: |- The **account** endpoints report your identity, tier limits, and live usage against them. Split in two on purpose: tier limits never change mid-session and cost nothing to fetch, while usage is a live figure that changes on every upload or strategy execution. **Key functionalities**: - Get your tier and its limits (dataset count, dataset size, total storage). - Get your live storage usage — datasets, strategy-execution signals, and registered strategies all count against one shared total. - name: Exchange description: |- This set of endpoints allows interaction with various **exchanges** for cryptocurrencies and financial assets. With these endpoints, users can access information about available exchanges, retrieve the instruments (currency pairs or assets) offered by each exchange, and perform analyses on them. The data provided by these endpoints is crucial for strategic decision-making within the trading platform. **Key functionalities**: - Retrieve the list of available exchanges on the platform. - Get the instruments available on a specific exchange (currency pairs, assets, etc.). - name: Backtesting description: |- The **backtest** endpoints enable users to test trading strategies based on historical data before applying them in real-time trading. This functionality is essential for traders who want to evaluate the performance of a strategy under past market conditions and optimize it before implementing it in a live trading environment. **Key functionalities**: - Prepare historical market data for fast access. - Run simulations of trading strategies using prepared historical market data. - Assess performance and optimize strategies based on past results and metrics. - name: Strategy description: |- The **strategy** endpoints allow users to submit, compile, and validate trading strategies within the platform. This feature is crucial for traders who develop automated trading systems and wish to ensure their strategy is both executable and effective before deploying it in a live/backtesting environment. **Key functionalities**: - Submit rich Java™ based trading strategies — or QTScript (beta) ones — for validation and compilation. - Ensure strategies meet the required syntax and operational constraints. - Validate strategies to identify potential issues or errors before execution. - Integrate strategies with specific exchanges and market instruments for tailored use. - name: Dataset description: |- The **dataset** endpoints let you upload your own historical ticker data and backtest against it the same way you would against a managed exchange — via the reserved `exchangeId: user` value on the existing `prepare`/`execute` endpoints. This is for data QTSurfer doesn't capture itself: your own exports, a venue not yet integrated, or a private feed. **Key functionalities**: - Create a dataset and get a presigned URL to upload a CSV or parquet file directly to object storage. - Finalize an upload to trigger validated ingest (column contract check, cadence and timestamp unit discovery, gap detection) and poll its result. - List and inspect your datasets, including their current ingested version. - Prepare and execute backtests against a dataset exactly like against a managed exchange. - Delete a dataset you no longer need. externalDocs: description: Find out more about QTSurfer API url: 'https://qtsurfer.com/developers'