openapi: 3.1.0 info: title: QTSurfer API summary: QTSurfer backend services API version: 0.128.25 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}`. With `includeDeleted=true`, strategies you have deleted are listed too, each with the `deletedAt` it was deleted at — useful to keep a copy of your list in sync, telling a deleted strategy apart from one that never existed. parameters: - name: includeDeleted in: query required: false description: | `true` also lists the strategies you have deleted, each with its `deletedAt`. Any other value, or leaving it out, lists only the ones you have not deleted. schema: type: boolean default: false 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 — whitespace and line or block comments before it are ignored — 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. A `200` means the source parsed and compiled, not that it will run. What only shows once the strategy sets up its indicators is found by `validate`: for QTScript, a window on an indicator name that is not registered (`window nosuch m1 { ... }`) registers, and `validate` ends `failed` on that line. 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' '413': description: The source is larger than 32 KiB (32768 bytes). Every endpoint that reads a request body caps it, and the message names the cap; see the Strategy guide. content: application/json: schema: $ref: '#/components/schemas/ResponseError' example: code: 413 message: The request body is larger than this endpoint accepts (32768 bytes at most). '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. A full `grid` with more combinations than your plan allows (`maxSweepCartesian` in `GET /account`) is refused with a message that asks for the `random` or `lhs` sampler. 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. Cancelling is a request, not a stop: the sweep reports `status: CANCELLED` (and `state.status: Aborted`) as soon as the request is accepted, while runs that were already in flight are still finishing. Until they have, `progress.pendingShards` is above `0`, and `progress.done` and the leaderboard can still grow after `CANCELLED` is first seen. A client that needs the final set of rows, to store them or to mark the rest as not run, keeps reading until `progress.pendingShards` is `0`; the wait is bounded by how long one run takes. 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`. With `includeDeleted=true`, datasets you have deleted are listed too, each with the `deletedAt` it was deleted at — useful to keep a copy of your list in sync, telling a deleted dataset apart from one that never existed. parameters: - name: includeDeleted in: query required: false description: | `true` also lists the datasets you have deleted, each with its `deletedAt`. Any other value, or leaving it out, lists only the ones you have not deleted. schema: type: boolean default: false 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 is many times your tier's size limit for a dataset. The limit itself applies to the stored size, which is known only after conversion, so an upload that passes here can still end `failed` when ingest finishes. 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 '/strategy/{strategyId}/live': post: operationId: startLive summary: Start a strategy on a live market feed description: | Starts your strategy against a live market feed. A new run begins in the **sandbox** stage — a short trial that compares an independent second execution against the first for agreement — before it is eligible for promotion to the live stage where it actually publishes signals other systems can act on. Poll `GET /strategy/{strategyId}/live` (or `PATCH`/`DELETE` `/live/{runId}` once you have the `runId`) to watch `stage` move from `SANDBOX` to `LIVE`. A strategy that has already been through that trial does not go through it again. When an earlier run of yours of the same compiled strategy was promoted to `LIVE`, and none of that compilation's runs was stopped for using more resources than allowed, the new run starts in `LIVE` at once: the response says `stage: LIVE` and carries the earlier verdict in `gate`. The parameters, sources and instruments may differ from the earlier run. Submitting the strategy again (`POST /strategy`) compiles it anew, and a new compilation goes through the sandbox like a first run. Send `sandbox: true` to start in the sandbox anyway — for example to read the run's signals back over the connection while you debug it, which the live stage does not keep. Only one run per strategy at a time — starting again while one is already running is `409`; stop the current one first. `sources` takes exactly one entry today (multi-source strategies are not supported yet). `type` is `ticker` or `kline`; anything else is rejected. Both connect to the lightest (fastest) cadence available for the exchange — today that is 1 tick/second on every supported exchange; choosing among several cadences is not offered yet. `instruments` can be left out. A QTScript strategy can declare which instruments it accepts with an `instruments` line in its source; when the compilation of the strategy records that selection as a list of pairs, a start that leaves `instruments` out, or sends `["*"]`, takes that list, and otherwise the run reads every instrument the exchange/segment offers. A list you send is taken as sent. Either side of an entry can be `*` (any base paired with USDT). The instruments the run reads come back in `sources`. See the "Live execution" guide, "Sources". `params` is passed straight through to the strategy at start — the same free-form object `POST /strategy/{strategyId}/validate` and the backtest endpoints already accept. To change a parameter **while the run is live**, use `PUT /live/{runId}/params` instead; this endpoint only sets the values a run starts with. Consuming a run's own output — its signals, and updating its parameters over a live connection instead of polling — is a WebSocket protocol on top of these REST endpoints; see the "Live execution" guide linked from this tag's description for the full flow (minting a connection token, the channel and RPC method). **Paper trading.** Pass a `paper` block to have the run's hints executed in simulation from its first tick, as a backtest would execute them: fills, closed trades, equity and KPIs, with one simulated account per quote currency. Omitted, the run has no paper trading. It takes the same economics as a backtest's `baseConfig` plus where its output goes; read it back with `GET /live/{runId}/paper`. A strategy that listens to its own execution events (it overrides `getExecutionCallback()`) has no other execution venue, so it cannot start without a `paper` block. **Warming up.** Pass `warmFrom` to choose how many seconds before its start the run replays the market feed from, so that its indicators and windows have history when the first live tick arrives. `0` replays nothing: the run delivers its first signal as soon as it is running, but its indicators start empty and the first bar of a window can be partial. Omitted, the platform replays from the start of the current 15-minute block, which is between 0 and 900 seconds before the run's start, so the first bar of a 15-minute window is complete. The value in effect, yours or the platform's, comes back as `warmFrom`. It can be set only here, when the run is started. See the "Live execution" guide, "Warming up". **A plain WebSocket stream.** Pass `stream: true` to get a secret URL for this run's signals that a simple client, or a service that passes them on to others, can open as an ordinary WebSocket, from the sandbox stage on. The URL is in the response (`streamUrl`) and in `GET /strategy/{strategyId}/live`; treat it like a password. See the "Live execution" guide. parameters: - name: strategyId in: path required: true description: The id returned by `POST /strategy` schema: $ref: '#/components/schemas/strategyId' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/StartLiveRequest' example: sources: - venueType: cx exchange: binance segment: spot type: ticker instruments: [BTC/USDT] visibility: private name: EMA cross paper: initialFunding: 1000 feeRate: 0.001 percentAmountToLock: 20 responses: '201': description: Started — the run's own state, in the sandbox stage (in the live stage for a strategy that has already been through the sandbox), with its `streamUrl` when you asked for a stream content: application/json: schema: $ref: '#/components/schemas/LiveRunWithStream' example: strategyId: 6bsh31ikwkuivhtgcoa6s4 runId: 5t5oAmQ4PD0lQRoCU58uE0 visibility: private stage: SANDBOX state: STARTING desired: RUNNING sources: - venueType: cx exchange: binance segment: spot type: ticker instruments: [BTC/USDT] params: {} paramsVersion: 0 relay: false startedAtMs: 1758330000000 '400': description: Malformed `sources` (not exactly one entry, missing field, unsupported `type`), a `type` the strategy cannot consume (a ticker strategy with a `kline` source, or the reverse; the message names both), an invalid `visibility`, an invalid `paper` block (an unknown field, a wrong type or an out-of-range value), or no `paper` block for a strategy that listens to its own execution events. A `relay` or `stream` that is not `true` or `false`, or a `stream` where streams are not available yet. A `warmFrom` that is not an integer from 0 to 3600. An `instruments` that is empty or `null`. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '404': description: No such registered strategy for this user, or it has never been compiled. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '409': description: A run for this strategy is already active. Stop it first. content: application/json: schema: $ref: '#/components/schemas/ResponseError' example: code: 409 message: A live run is already active for strategy 6bsh31ikwkuivhtgcoa6s4 -- stop it before starting another '429': description: | One of two kinds. **Your plan** does not include live runs, would exceed your concurrent-run or instrument-count limit, or does not let you ask for a `stream`: the message names your plan, and retrying will not help until something changes, so there is no `Retry-After`. Or **the platform is at capacity** right now: a `Retry-After` header says when to try again. headers: Retry-After: schema: type: integer description: Seconds to wait before retrying. Only on the capacity kind. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Live Execution get: operationId: getLive summary: Get this strategy's current (or most recent) live run description: | The run you last started for this strategy — its most complete state, including `params` and the promotion `gate` once the sandbox trial has one to report. Returns the run's last known state even after it has stopped; this endpoint never disappears history. When the run was started with a `stream`, `streamUrl` is here too while the run is running and your plan allows it. While the run is being executed it also carries `stats`, the run's latest counters (updates processed, rate, instruments seen), refreshed about once a minute. Starting or stopping a run does not return them; read them here. parameters: - name: strategyId in: path required: true description: The id returned by `POST /strategy` schema: $ref: '#/components/schemas/strategyId' responses: '200': description: The run's current state, with its `streamUrl` when it has one content: application/json: schema: $ref: '#/components/schemas/LiveRunWithStream' '404': description: You have never started a live run for this strategy. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Live Execution delete: operationId: stopLive summary: Stop this strategy's live run description: | Requests a stop. The run winds down at its own next check-in rather than instantly — poll `GET`/`PATCH` `.../live` and expect `state` to remain `RUNNING` for a short window after `desired` flips to `STOPPED`. Calling this again on an already-stopped run is not an error; it returns the same (unchanged) state. A stopped run has no stream: its `streamUrl`, if it had one, stops working and is not in this response. parameters: - name: strategyId in: path required: true description: The id returned by `POST /strategy` schema: $ref: '#/components/schemas/strategyId' responses: '200': description: Stop requested (or already stopped) — the run's current state content: application/json: schema: $ref: '#/components/schemas/LiveRun' '404': description: You have never started a live run for this strategy. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Live Execution '/live': get: operationId: listLive summary: List your own live runs description: | Every run you have started, in any `stage`, `desired` state, or `visibility` — newest first. Unlike `GET /live/public`, this is not filtered to `RUNNING` public runs: it is the complete list of runs you own, including `sandbox` trials and stopped ones. parameters: - name: cursor in: query description: The `runId` from a previous page's `_links.next.href`. Omit for the first page. schema: type: string maxLength: 64 - name: limit in: query description: Page size. Larger values are capped, not rejected. schema: type: integer default: 20 maximum: 100 responses: '200': description: A page of your own runs content: application/json: schema: $ref: '#/components/schemas/LiveListResponse' example: runs: - strategyId: 6bsh31ikwkuivhtgcoa6s4 runId: 5t5oAmQ4PD0lQRoCU58uE0 name: EMA cross visibility: private stage: LIVE state: RUNNING desired: RUNNING sources: - venueType: cx exchange: binance segment: spot type: ticker instruments: [BTC/USDT] createdAtMs: 1758330000000 startedAtMs: 1758330015000 _links: next: href: '/v1/live?limit=20&cursor=5t5oAmQ4PD0lQRoCU58uE0' '400': description: An invalid `cursor` or `limit`. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Live Execution '/live/public': get: operationId: listPublicLive summary: Browse public live runs description: | Every run whose owner marked it `public`, that has been promoted to `live` and is running — anyone's, yours included, and listed without revealing who owns it. A `public` run still in its `sandbox` trial is not listed. Most recently started first. This is the only `Live Execution` endpoint that needs no `Authorization` header. parameters: - name: cursor in: query description: The `runId` from a previous page's `_links.next.href`. Omit for the first page. schema: type: string maxLength: 64 - name: limit in: query description: Page size. Larger values are capped, not rejected. schema: type: integer default: 20 maximum: 100 responses: '200': description: A page of public runs content: application/json: schema: $ref: '#/components/schemas/PublicLiveListResponse' example: runs: - runId: 5t5oAmQ4PD0lQRoCU58uE0 name: EMA cross sources: - venueType: cx exchange: binance segment: spot type: ticker instruments: [BTC/USDT] state: RUNNING createdAtMs: 1758330000000 _links: next: href: '/v1/live/public?limit=20&cursor=5t5oAmQ4PD0lQRoCU58uE0' '400': description: An invalid `cursor` or `limit`. content: application/json: schema: $ref: '#/components/schemas/ResponseError' tags: - Live Execution '/live/{runId}': get: operationId: getLiveRun summary: Read one of your runs by its id description: | Owner only, addressed by `runId` directly rather than through its strategy: the run's own canonical identity, which is what `POST`/`GET`/`DELETE` `.../live`, `GET /live` and `GET /live/public` all hand out. Use it to re-read one specific run — including one you stopped long ago or that is no longer your strategy's most recent — without paging through `GET /live`. Returns the same state as `GET /strategy/{strategyId}/live` (`strategyId` is the strategy's id, and `stats` is there too), plus `updatedAtMs`: when the run last changed, from any cause — a state change, a promotion, a parameter update, a stop. A refresh of `stats` is not a change of the run and does not move `updatedAtMs`. A run that is public is reachable by others through `GET /live/public`, never through this route. parameters: - name: runId in: path required: true description: The `runId` from `POST`/`GET`/`DELETE` `.../live`, from `GET /live` or from `GET /live/public` schema: type: string responses: '200': description: The run content: application/json: schema: $ref: '#/components/schemas/LiveRunDetail' example: strategyId: 0dyfzg599rfwngikrosiw2 runId: 5t5oAmQ4PD0lQRoCU58uE0 name: EMA cross visibility: private stage: SANDBOX state: RUNNING desired: RUNNING sources: - venueType: cx exchange: binance segment: spot type: ticker instruments: [BTC/USDT] params: {} paramsVersion: 1 relay: false startedAtMs: 1790790087954 updatedAtMs: 1790790092722 '404': description: No such run, or you do not own it — the two look identical on purpose. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Live Execution patch: operationId: updateLive summary: Change a run's visibility, name, or description description: | Owner only, addressed by `runId` directly rather than through its strategy — this is a run's own canonical identity, independent of which strategy started it. Setting `visibility` from `public` back to `private` also evicts anyone currently connected to the run's live signal channel who is not its owner — best-effort; the change to this record is not rolled back if that eviction fails. parameters: - name: runId in: path required: true description: The `runId` from `POST`/`GET`/`DELETE` `.../live` or from `GET /live/public` schema: type: string requestBody: content: application/json: schema: $ref: '#/components/schemas/UpdateLiveRequest' example: visibility: public responses: '200': description: Updated content: application/json: schema: $ref: '#/components/schemas/LiveRunCompact' example: runId: 5t5oAmQ4PD0lQRoCU58uE0 name: EMA cross visibility: public stage: LIVE state: RUNNING sources: - venueType: cx exchange: binance segment: spot type: ticker instruments: [BTC/USDT] '400': description: '`visibility` present but not `private`/`public`.' content: application/json: schema: $ref: '#/components/schemas/ResponseError' '404': description: No such run, or you do not own it — the two look identical on purpose. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Live Execution '/live/{runId}/params': put: operationId: updateLiveParams summary: Change a running strategy's parameters description: | Updates one or more parameters of a run **while it stays live** — unlike `params` on `POST /strategy/{strategyId}/live`, which only sets the starting values. Owner only. Every key in `params` must be one your strategy declares (see `declaredProperties` on `POST /strategy`) as of the compilation this run is executing — recompiling the strategy later never changes what an already-running instance accepts; start a new run for that. The change does not take effect the instant this call returns: `effectiveAtMs` is the earliest moment it is guaranteed to apply, a few seconds out, so both the REST and WebSocket paths to this same update (see the "Live execution" guide) land on the exact same value at the exact same moment. parameters: - name: runId in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdateLiveParamsRequest' example: params: emaFastPeriod: '12' responses: '200': description: Accepted content: application/json: schema: $ref: '#/components/schemas/LiveParamsUpdateResult' example: runId: 5t5oAmQ4PD0lQRoCU58uE0 paramsVersion: 2 effectiveAtMs: 1758330015000 '400': description: '`params` missing, not an object, empty, or contains a key your strategy does not declare.' content: application/json: schema: $ref: '#/components/schemas/ResponseError' '404': description: No such run, or you do not own it. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '409': description: This run's compiled strategy has no record of the parameters it declares. Register the strategy again with `POST /strategy` and start a new run, since a run keeps the compiled version it started with. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Live Execution '/live/{runId}/commands': post: operationId: sendLiveCommand summary: Tell a running strategy a command description: | Tells a running strategy something **while it stays live**, without restarting it — for a strategy that implements the engine's `CommandRequestHandler`. Owner only. A command is an event, not a stored setting: it is delivered once to every execution behind the run, at the same market position, and nothing about it is written to the run's state. **It is transient** — a replica that restarts replays only its recent market history, so a command from before that only reaches one that was already running when it arrived. Anything the strategy needs to remember across a restart belongs in a parameter (`PUT /live/{runId}/params`), which does have a stored value; a command does not. The command's text is a plain string; an optional `properties` object of your own choosing travels alongside it. Each entry lands as a top-level entry on the `CommandRequest` the handler receives — `request.get("")` in Java, `$command.` sugar in QTScript — no key is off limits, since the command's own text is kept separately. parameters: - name: runId in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/SendLiveCommandRequest' example: command: flatten properties: instrument: BTC/USDT responses: '202': description: Accepted — on its way to every execution behind the run. content: application/json: schema: $ref: '#/components/schemas/LiveCommandResult' example: runId: 5t5oAmQ4PD0lQRoCU58uE0 commandId: 0e3f2f1a-9c4b-4d3e-8a2f-6b7c5d4e3f21 effectiveAtMs: 1758330015000 '400': description: >- `command` missing, not a string, blank; `properties` given but not an object; or the body carries any key other than `command` and `properties`. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '404': description: No such run, or you do not own it. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '409': description: | One of three reasons, each its own message: the run is not running, so there is nothing to tell; this run's compiled strategy has no record of whether it handles commands — register the strategy again with `POST /strategy` and start a new run, since a run keeps the compiled version it started with; or the strategy does not implement `CommandRequestHandler` at all. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '413': description: The request body is larger than 2 KiB (2048 bytes). Every endpoint that reads a request body caps it, and the message names the cap. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '503': description: The command could not be delivered right now, and it was not sent — unlike a parameter update, a command has no fallback path. Try again. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Live Execution '/live/{runId}/stream': post: operationId: rotateLiveStream summary: Rotate a run's stream URL description: | Gives a run a **new** stream URL and retires the old one: connections open on the old URL are closed within about 15 seconds and the old URL answers `404` from then on. Use it when the URL may have leaked. Owner only, and only for a run that is running and was started with `stream: true` (the URL cannot be added to a run later, and a revoked one cannot be brought back). The plan is checked again: a plan that may not broadcast gets `429`. See the "Live execution" guide, "A plain WebSocket stream of a run". parameters: - name: runId in: path required: true schema: type: string responses: '200': description: The new URL. content: application/json: schema: $ref: '#/components/schemas/LiveStreamUrl' '404': description: No such run, or you do not own it. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '409': description: | One of three reasons, each its own message: the run was started without a stream; its stream was revoked (a revoked stream cannot be restored: start the run again with a stream); or the run is stopped. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '429': description: Your plan does not let you broadcast a run's signals. The message names your plan; retrying will not help until it changes, so there is no `Retry-After`. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Live Execution delete: operationId: revokeLiveStream summary: Revoke a run's stream URL description: | Revokes a run's stream URL **for good**: connections open on it are closed within about 15 seconds and it answers `404` from then on. The run itself is not touched, and a stream cannot be added to it again; start the run again with `stream: true` for a new URL. Owner only, and always allowed, whatever your plan or the run's state. Repeating it is not an error. parameters: - name: runId in: path required: true schema: type: string responses: '200': description: Revoked (or already revoked). content: application/json: schema: $ref: '#/components/schemas/LiveStreamRevoked' '404': description: No such run, or you do not own it. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '409': description: The run was started without a stream, so there is nothing to revoke. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Live Execution '/live/{runId}/signals': get: operationId: getLiveRunSignals summary: Read a run's signals description: | Returns one page of the signals a run has already produced, newest-last, optionally from a given time and narrowed to one or more instruments. This is the counterpart to the real-time WebSocket channel: that channel only carries what happens while you are connected, and only for a run that asked for `relay`. A run's signals are recorded either way, so this endpoint serves them whether or not `relay` was ever on, in both the `sandbox` and `live` stages — use it to catch up after a disconnect, to read a run you never relayed, or to page back over what has already happened. **The available window moves.** Signals are kept for a limited span, and the oldest are continuously discarded as new ones arrive, so how far back you can read is not a fixed number of hours: on a busy run it can be a good deal shorter. Every response carries `availableSinceMs`, the oldest moment that can still be answered for. Asking for a `sinceMs` older than that is not an error — you get everything from `availableSinceMs` onwards, and that field tells you it happened. **A cursor can expire, and on a busy run it expires quickly.** If the position a cursor points at has since been discarded, the next page answers `410` rather than silently serving a shortened page that looks complete. Treat that as a normal outcome: read `availableSinceMs` from the error and start again from there. Readable by the run's owner, and by anyone if the run is `public` and has reached the `live` stage — the same rule the signal channel applies to a subscription. A `sandbox` run is read by its owner only, whatever visibility it asked for. parameters: - name: runId in: path required: true schema: type: string description: The run whose signals to read. - name: sinceMs in: query required: false schema: type: integer format: int64 description: Start from this moment (epoch ms). Omitted, or older than the available window, starts at `availableSinceMs`. Ignored when `cursor` is given. - name: instrument in: query required: false schema: type: string description: | Narrow to one or more instruments. Omitted, or `*`, returns every instrument the run covers. Accepts a single pair (`BTC/USDT`), either half as a wildcard (any base paired with that quote, or BTC paired with any quote), or a comma-separated list of pairs (`BTC/USDT,ETH/EUR`). Symbols are matched exactly, case included — pass them as this API reports them. - name: type in: query required: false schema: type: string description: Narrow to one or more signal types, comma-separated (`hint`, `info`, `marker`, `command`, `paper`). Combines with `instrument`. - name: cursor in: query required: false schema: type: string description: Continue from a previous page's `_links.next`. Takes precedence over `sinceMs`. - name: limit in: query required: false schema: type: integer minimum: 1 maximum: 100 default: 20 description: Page size. responses: '200': description: One page of signals content: application/json: schema: $ref: '#/components/schemas/LiveSignalPage' example: signals: - v: 1 signalId: 5t5oAmQ4PD0lQRoCU58uE0-000042 runId: 6TzAPiPpsOWwBLdLBZCxwH stage: live paramsVersion: 2 type: hint kind: BUY eventTsMs: 1758330012000 emittedAtMs: 1758330012040 instrument: exchange: binance segment: spot symbol: BTC/USDT order: orderKind: MARKET price: null amount: null stopPrice: null trailPct: null data: {} regenerated: false digest: 9f2c… availableSinceMs: 1757725212000 _links: next: href: '/v1/live/6TzAPiPpsOWwBLdLBZCxwH/signals?cursor=eyJzZXEiOjQyfQ&limit=20' '400': description: '`instrument`, `type`, `sinceMs`, `limit` or `cursor` is malformed.' content: application/json: schema: $ref: '#/components/schemas/ResponseError' '404': description: No such run, or not one you may read. content: application/json: schema: $ref: '#/components/schemas/ResponseError' '410': description: The cursor's position is no longer available — restart from the `availableSinceMs` named in the message. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Live Execution '/live/{runId}/paper': get: operationId: getLiveRunPaper summary: Read a run's paper trading description: | The run's paper trading as last recorded: one entry per simulated account (one per quote currency the run trades — never added together), with its starting capital, current equity, realised PnL, open positions and KPIs. The KPIs are the same a backtest reports, computed over the trades closed so far. `equity` is the account's latest recorded value: at the last closed trade (`equityKind: equity`), or the last periodic mark-to-market while positions are open (`equityKind: mark`, taken every minute of market time). Until either exists the account holds its starting capital. Readable by the run's owner, and by anyone if the run is `public` and has reached the `live` stage. A run started without a `paper` block answers `404`. parameters: - name: runId in: path required: true schema: type: string responses: '200': description: The run's paper accounts content: application/json: schema: $ref: '#/components/schemas/LivePaper' example: runId: 6TzAPiPpsOWwBLdLBZCxwH stage: SANDBOX accounts: - currency: USDT initialFunding: 1000 equity: 996.4 equityAtMs: 1758330060000 equityKind: mark realisedPnl: -2.1 trades: 14 gaps: 0 openPositions: - instrument: BTC/USDT base: 0.0023 cost: 194.2 kpi: totalTrades: 14 winCount: 6 lossCount: 8 winRate: 0.4286 pnlTotal: -2.1 pnlTotalPercent: -0.21 sharpeRatio: -0.08 sortinoRatio: -0.11 cagr: -0.41 maxDrawdown: 3.4 maxDrawdownPercent: 0.34 '404': description: No such run, not one you may read, or a run without paper trading. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Live Execution '/live/{runId}/paper/equity': get: operationId: getLiveRunPaperEquity summary: Read a run's paper equity curve description: | The run's paper equity curve, oldest first, page by page. It is kept for the life of the run, so unlike signals it has no moving window. Points are `equity` at every closed trade, `mark` every minute of market time while positions are open, and `gap` where the run was restarted with positions open: those positions are not carried over, so the curve has no value there. `currency` narrows to one account; without it, every account's points come interleaved by time, each carrying its currency. Readable by the run's owner, and by anyone if the run is `public` and has reached the `live` stage. parameters: - name: runId in: path required: true schema: type: string - name: currency in: query required: false schema: type: string description: One account's quote currency, e.g. `USDT`. - name: sinceMs in: query required: false schema: type: integer format: int64 description: Start from this market time (epoch ms). Ignored when `cursor` is given. - name: cursor in: query required: false schema: type: string description: Continue from a previous page's `_links.next`. - name: limit in: query required: false schema: type: integer minimum: 1 maximum: 1000 default: 100 responses: '200': description: One page of the curve content: application/json: schema: $ref: '#/components/schemas/LivePaperEquityPage' example: points: - currency: USDT kind: equity eventTsMs: 1758330012000 equity: 999.2 - currency: USDT kind: mark eventTsMs: 1758330060000 equity: 996.4 _links: next: href: '/v1/live/6TzAPiPpsOWwBLdLBZCxwH/paper/equity?cursor=eyJ0cyI6MTc1ODMzMDA2MDAwMH0&limit=2¤cy=USDT' '400': description: '`sinceMs`, `limit` or `cursor` is malformed.' content: application/json: schema: $ref: '#/components/schemas/ResponseError' '404': description: No such run, not one you may read, or a run without paper trading. content: application/json: schema: $ref: '#/components/schemas/ResponseError' security: - bearerAuth: [] tags: - Live Execution '/live/token': post: operationId: mintLiveConnectionToken summary: Mint a WebSocket connection token description: | Mints a short-lived token for the WebSocket connection used to receive a run's signals in real time and to call `live.params` (the WebSocket form of `PUT /live/{runId}/params`) — see the "Live execution" guide linked from this tag's description for the full protocol. Carries no request body. responses: '200': description: Token minted content: application/json: schema: $ref: '#/components/schemas/LiveConnectionToken' example: token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI4YWViOTljYi01NjcyLTQ1ZmItYWJiNi02NDU4NTk1NzJkMDAiLCJpYXQiOjE3OTAwOTkxNTksImV4cCI6MTc5MDA5OTc1OX0.signature expiresAtMs: 1758502200000 security: - bearerAuth: [] tags: - Live Execution 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, maxExecute, maxRangeDays, maxSweepCartesian, maxImportRangeHours, 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" maxExecute: type: integer description: | Maximum number of strategy executions (and sweeps) you can have running at the same time through the API. Starting one past this number is answered with `429`, whose message carries the same number. The value already includes any API allowance your plan has. example: 10 maxRangeDays: type: integer description: Maximum length, in days, of the time range of a backtest on one of your own datasets. example: 7 maxSweepCartesian: type: integer description: | Largest full grid, in parameter combinations, a sweep may run with the `grid` sampler. A grid with more combinations is refused with `400`; the `random` and `lhs` samplers run only their `samples` and are not held to it. example: 100 maxImportRangeHours: type: integer description: Maximum length, in hours, of the time range of one dataset import from an exchange. example: 6 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 as stored, that is the `bytes` of its ready version. For a CSV upload that is the converted file, not the file you upload, so estimate from the number of rows. The Datasets guide has the details. 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 description: | `grid` runs every combination of the axes and is held to your plan's `maxSweepCartesian` (`GET /account`); `random` and `lhs` run `samples` combinations and are not. 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 description: Share of the free balance each entry locks, in percent (0-100]. Omitted, a backtest sizes every entry with everything available. 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 description: >- Shards (or folds) that have not finished yet. On a cancelled sweep, `0` is what says every run that was in flight has finished and the leaderboard is complete: `CANCELLED` is reported before that. 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 deletedAt: type: string format: date-time description: | When you deleted this strategy. Only ever present in `GET /strategies?includeDeleted=true`, and only on strategies you have deleted. 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" deletedAt: type: string format: date-time description: | When you deleted this dataset. Only ever present in `GET /datasets?includeDeleted=true`, and only on datasets you have deleted. 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. LiveSource: type: object required: [venueType, exchange, segment, type, instruments] description: One market feed a live run consumes. Exactly one entry per run today. properties: venueType: type: string example: cx description: Venue category. `cx` (centralized exchange) is the only one live runs support today. exchange: type: string example: binance segment: type: string example: spot type: type: string enum: [ticker, kline] description: 'Both `ticker` and `kline` connect to the lightest (fastest) cadence available for the exchange — today, 1 tick/second on every supported exchange. Choosing a specific cadence is not offered yet.' instruments: type: array items: type: string example: [BTC/USDT] description: >- The instruments the run reads. For a run started with a list, that list as sent; for one started without `instruments`, or with `["*"]`, what the start resolved: the list of pairs the compilation of the strategy records as the instruments it accepts, when it records one, or `["*"]` for every instrument the exchange/segment offers (tier-gated). An entry is written `BASE/QUOTE` and either side can be `*` to mean any, such as any base paired with USDT. Symbols are not case-sensitive and spaces around them are ignored, so `btc/usdt` and `BTC/USDT` are the same instrument. LiveSourceRequest: type: object required: [venueType, exchange, segment, type] description: >- One market feed to start a live run on. Exactly one entry per run today. It is `LiveSource` as you ask for it: the only difference is that `instruments` can be left out. properties: venueType: type: string example: cx description: Venue category. `cx` (centralized exchange) is the only one live runs support today. exchange: type: string example: binance segment: type: string example: spot type: type: string enum: [ticker, kline] description: 'Both `ticker` and `kline` connect to the lightest (fastest) cadence available for the exchange — today, 1 tick/second on every supported exchange. Choosing a specific cadence is not offered yet.' instruments: type: array items: type: string example: [BTC/USDT] description: >- Which instruments the run reads. A QTScript strategy can declare which instruments it accepts with an `instruments` line in its source. When the compilation of the strategy records that selection as a list of pairs, leaving `instruments` out, or sending `["*"]`, takes that list; otherwise the run reads every instrument the exchange/segment offers. A list of instruments is taken as sent, and `[]` and `null` are refused with `400`. An entry is written `BASE/QUOTE`; either side can be `*` to mean any: any base can be paired with USDT, and BTC can be paired with any quote. Entries are not case-sensitive and spaces around them are ignored, so `btc/usdt` and `BTC/USDT` are the same instrument. An entry that is not of that form (no `/`, an empty side, a `.` or a space inside a side, or a `*` mixed with other characters of a side) makes the run fail when it starts. Plan limits: a list with an entry that has a `*` on a side counts like `["*"]` and only plans that include the wildcard accept it; the other entries are counted one by one. The instruments the run actually reads come back in the run's `sources`. StartLiveRequest: type: object required: [sources] properties: sources: type: array minItems: 1 maxItems: 1 items: $ref: '#/components/schemas/LiveSourceRequest' params: type: object description: Strategy parameters to start with. Opaque key/value pairs — see this strategy's own `declaredProperties` (from `POST /strategy`) for the keys it accepts. additionalProperties: true visibility: type: string enum: [private, public] default: private description: A `public` run appears in `GET /live/public` and its signal channel accepts subscriptions from anyone, not only you — from the moment it is in the `live` stage, which is when it is promoted or, for a run that starts there, at once. While it is a `sandbox` trial, `public` is only what you asked for, and only you can read it. relay: type: boolean default: false description: Request that this run's signals be relayed over its WebSocket channel, from its first signal — in the `sandbox` stage too, where only you can subscribe to it. See the "Live execution" guide. sandbox: type: boolean default: false description: | Start in the `sandbox` stage even though an earlier run of yours already took this compiled strategy through it. Without it, such a strategy starts in `LIVE` at once; with `true` it starts in `SANDBOX` like a first run, which is how to read its signals back over the connection (`history`, kept for the `sandbox` stage only) while you debug it. For a strategy that has not been through the sandbox it changes nothing: it always starts there. Anything other than `true` or `false` is `400`. stream: type: boolean default: false description: | Ask for a **stream URL**: a secret address that a simple client, or a service that passes your signals on to others, can open as a plain WebSocket to receive this run's signals as they are produced, one JSON text frame per signal, with none of the live-execution protocol around it. The URL comes back as `streamUrl` in the response to this call. Available from the `sandbox` stage on, on the plans that may broadcast; any other plan gets `429`. It can be asked for **only when the run is started** (not added later), and it also turns `relay` on. See the "Live execution" guide, "A plain WebSocket stream of a run". warmFrom: type: integer minimum: 0 maximum: 3600 description: | How many seconds before the run's start to replay the market feed from, so the strategy's indicators and windows have history when its first live tick arrives. `0` replays nothing: the run delivers its first signal as soon as it is running, but its indicators start empty and the first bar of a window can be partial. Omitted, the platform replays from the start of the current 15-minute block: between 0 and 900 seconds before the run's start, depending on when it starts (0 if it starts exactly on a quarter hour), so the first bar of a 15-minute window is complete. The value in effect, the one you sent or the one the platform chose, is reported back as `warmFrom` on the run. Signals about the replayed time are not pushed on the run's channel or stream: they describe events from before the run started. It can be set **only when the run is started**: it is not a parameter of `PUT /live/{runId}/params`, and changing it means stopping the run and starting it again. Anything that is not an integer from 0 to 3600 is `400`. See the "Live execution" guide, "Warming up". name: type: string description: type: string paper: $ref: '#/components/schemas/LivePaperConfig' LivePaperConfig: type: object additionalProperties: false description: | Paper trading for this run: the same economics as a backtest's `baseConfig` (same fields, defaults and limits), plus `output`. An empty object takes every default. Each quote currency the run trades gets its own simulated account, opened with `initialFunding` in that currency; accounts are never added together. As returned on a run, the block is normalised: `feeRate` is resolved into `buyFeeRate`/`sellFeeRate` and defaults are filled in. properties: initialFunding: type: number format: double exclusiveMinimum: 0 maximum: 1000000000 default: 100 description: Starting capital of each account, in that account's own quote currency. feeRate: type: number format: double minimum: 0 default: 0.001 description: Fee rate for both sides (0.001 = 0.1%). Accepted on start; returned resolved into the two fields below. buyFeeRate: type: number format: double minimum: 0 description: Buy-side fee rate; overrides `feeRate`. sellFeeRate: type: number format: double minimum: 0 description: Sell-side fee rate; overrides `feeRate`. feeLeg: type: string enum: [RECEIVED, QUOTE, BASE] default: RECEIVED description: Which asset fees are charged in. Case-insensitive on start. percentAmountToLock: type: number format: double exclusiveMinimum: 0 maximum: 100 description: Share of the account's free balance each entry locks, in percent. Omitted, the strategy's own setting applies, and without one 10%. output: type: string enum: [separate, mix] default: separate description: '`separate` keeps paper output out of the run''s signals (read it with `GET /live/{runId}/paper`); `mix` also interleaves it into the run''s signals as `type: paper`, right after the signal that caused it.' UpdateLiveRequest: type: object properties: visibility: type: string enum: [private, public] name: type: string description: type: string UpdateLiveParamsRequest: type: object required: [params] properties: params: type: object additionalProperties: true description: Must be non-empty; every key must be a property your strategy declares. SendLiveCommandRequest: type: object required: [command] properties: command: type: string description: The command's text — non-blank. properties: type: object additionalProperties: true description: >- An optional map of your own choosing, alongside command. Absent means none; when given, it must be a JSON object, and `command` and `properties` are the only keys the body may carry. Each entry lands as a top-level entry on the strategy's `CommandRequest` — no key is off limits, since the command's own text is kept separately. LiveRun: type: object required: [strategyId, runId, visibility, stage, state, desired, sources, params, paramsVersion, relay, startedAtMs] description: >- A live run's full state, as returned by stopping it through its strategy, and the base of the representations returned by starting and by reading it. `warmFrom` is declared by those two (`LiveRunWithStream` and `LiveRunDetail`) and not here, because the response to stopping a run does not carry it. properties: strategyId: $ref: '#/components/schemas/strategyId' runId: type: string description: This run's own id — its canonical identity for `PATCH`/`PUT .../params` and for `GET /live/public`. name: type: string description: type: string visibility: type: string enum: [private, public] stage: type: string enum: [SANDBOX, LIVE] description: 'A new run starts `SANDBOX`, a 24-hour trial in which it is compared against a second execution and checked for resource use and stability. A run that passes moves to `LIVE` automatically when the 24 hours are up. A run of a strategy that an earlier run of yours already took through the trial starts `LIVE`, unless you started it with `sandbox: true`.' state: type: string description: 'The run''s health right now: `STARTING` (no runner has reported on it yet), `RUNNING`, `LAGGING` (behind the market data, usually while catching up; clears by itself), `HUNG` (stuck inside one strategy call for longer than allowed; clears when it returns), `DEGRADED` (its independent executions produced different signals), `FAILED` (refused, could not start or failed while running; `reason` says why) or `STOPPED`. `LAGGING`, `HUNG` and `DEGRADED` come and go on a running run. The set may grow: read an unknown value as a running run with something to look at. See the Live execution guide.' desired: type: string enum: [RUNNING, STOPPED] description: What you last asked for. `state` can lag this briefly after `DELETE`. reason: type: string description: | Why the run stopped or failed, when there is something to say; absent otherwise. It is never a stack trace or an internal message. Either `resource: ...` (the platform stopped the run for exceeding its resource allowance; the text says which limit) or one of a fixed set of sentences for a `FAILED` run: the strategy cannot consume the source type the run was started with, the run's definition was refused, the run could not start after several attempts, the strategy failed while processing data, the run lost its data feed, or the generic `The run failed.`. The set may grow: read an unrecognised sentence as a failure and do not parse it. A `FAILED` run usually stays `desired: RUNNING` until you stop it, and counts as active (`409` on a new start, and toward your live-run limit) until then; one that can never run because its strategy cannot consume its source type is stopped by the platform itself (`desired: STOPPED`), so it holds no place. sources: type: array items: $ref: '#/components/schemas/LiveSource' params: type: object additionalProperties: true paramsVersion: type: integer description: Increments on every accepted `PUT .../params` call, including one that resends the current values. relay: type: boolean description: Whether this run's signals are relayed over the WebSocket channel described in the "Live execution" guide. It is the value requested at start, in either stage. startedAtMs: type: integer format: int64 description: Epoch milliseconds. gate: type: object additionalProperties: true description: 'The sandbox trial''s promotion verdict. Absent for the whole 24-hour trial and present once it ends, so an absent `gate` means the trial has not finished. A run that started in `LIVE` without a trial has it from the start: the verdict of the earlier run it relies on, with `inheritedFrom` naming that run. `passed` is the verdict; the rest is diagnostic detail whose shape is not yet stabilized as public API: treat it as opaque.' paper: allOf: - $ref: '#/components/schemas/LivePaperConfig' description: The run's paper trading configuration as accepted at start, normalised. Absent when the run has no paper trading. stats: allOf: - $ref: '#/components/schemas/LiveRunStats' description: | The run's latest counters. Returned by the two reads, `GET /strategy/{strategyId}/live` and `GET /live/{runId}`; never by starting or stopping a run. Absent until the first snapshot exists (a run that has just started), and absent is not zero. LiveRunStats: type: object required: [processed, opsPerSecond, instrumentsSeen, asOfMs, stale] description: | What a run has been doing, as of its last snapshot. The platform refreshes it about once a minute while the run is being executed, so `opsPerSecond` is an average over that interval, not an instantaneous rate. properties: processed: type: integer format: int64 description: Updates of instruments the run has accepted since it started executing. It can start again from zero if the run is restarted. opsPerSecond: type: number description: Updates accepted per second over the last refresh interval. `0` when none arrived. instrumentsSeen: type: integer description: How many distinct instruments the run has received an update for. asOfMs: type: integer format: int64 description: Epoch milliseconds when these counters were last written. progressedAtMs: type: integer format: int64 description: | Epoch milliseconds of the last snapshot in which `processed` had grown. Absent until the run has processed anything. A run fed by a source that updates rarely (a funding rate, for example) can stay flat for hours: that is how such a run behaves, not a fault. stale: type: boolean description: | `true` when the run is meant to be running (`desired` is `RUNNING`) and its counters have not been refreshed for several refresh intervals: the platform has stopped updating them, which is worth checking against `state`. `false` otherwise. A run whose `processed` is flat is not stale; `progressedAtMs` is how to tell it apart. WarmFrom: type: integer minimum: 0 maximum: 3600 description: >- The `warmFrom` in effect for this run, in seconds: the one requested when the run was started or, when none was, the one the platform chose (the distance from the run's start back to the start of the current 15-minute block, 0 to 900). It is never `null`. It is present on every run started since the field exists and absent from a run started before it existed. It never changes while the run exists. LiveRunDetail: description: A run as returned by `GET /live/{runId}`, the full state of `LiveRun` plus when it last changed. allOf: - $ref: '#/components/schemas/LiveRun' - type: object required: [updatedAtMs] properties: warmFrom: $ref: '#/components/schemas/WarmFrom' updatedAtMs: type: integer format: int64 description: Epoch milliseconds of the run's last change, from any cause. It only moves forward, so of two reads of the same run the one with the larger value is the newer. LiveRunWithStream: description: | A run as `LiveRun` describes it, plus its `warmFrom` (absent only for a run started before the field existed) and its `streamUrl` when it has one. It is what **starting a run** and `GET /strategy/{strategyId}/live` return: your own run, read with your own credentials. Stopping a run returns `LiveRun`, which carries neither `warmFrom` nor the URL, and `GET /live/{runId}` returns `LiveRunDetail`, which carries `warmFrom` and never the URL. allOf: - $ref: '#/components/schemas/LiveRun' - type: object properties: warmFrom: $ref: '#/components/schemas/WarmFrom' streamUrl: type: string format: uri description: | The run's secret stream URL (`wss://…`). **Treat it like a password**: anyone who holds it can read this run's signals, from the sandbox stage on. Present only when the run was started with `stream: true`, is wanted running, has not had its stream revoked, and your plan still lets you broadcast; absent otherwise (a plan that lets you broadcast again gets the same URL back). Rotate it with `POST /live/{runId}/stream` if it leaks, revoke it with `DELETE /live/{runId}/stream`. LiveStreamUrl: type: object required: [streamUrl] description: A run's new stream URL, as returned by `POST /live/{runId}/stream`. properties: streamUrl: type: string format: uri description: The run's new secret stream URL. The previous one has stopped working. LiveStreamRevoked: type: object required: [runId, revoked] description: What `DELETE /live/{runId}/stream` returns. properties: runId: type: string revoked: type: boolean description: Always `true`. LiveRunCompact: type: object required: [runId, visibility, stage, state, sources] description: A run's state as returned by `PATCH /live/{runId}` — narrower than `LiveRun` (no `strategyId`, `params`, or `desired`), since this endpoint is addressed by `runId` alone. properties: runId: type: string name: type: string description: type: string visibility: type: string enum: [private, public] stage: type: string enum: [SANDBOX, LIVE] state: type: string sources: type: array items: $ref: '#/components/schemas/LiveSource' LiveRunSummary: type: object required: [strategyId, runId, visibility, stage, state, desired, sources, createdAtMs, startedAtMs] description: A run as it appears in `GET /live` — one of your own, narrower than `LiveRun` (no `params`, `paramsVersion`, `relay`, or `gate`), since listing stays cheap regardless of how many runs you have. Check a specific run's full state with `GET /strategy/{strategyId}/live`. properties: strategyId: $ref: '#/components/schemas/strategyId' runId: type: string description: This run's own id — its canonical identity for `PATCH`/`PUT .../params` and for `GET /live/public`. name: type: string description: type: string visibility: type: string enum: [private, public] stage: type: string enum: [SANDBOX, LIVE] description: 'A new run starts `SANDBOX`, a 24-hour trial in which it is compared against a second execution and checked for resource use and stability. A run that passes moves to `LIVE` automatically when the 24 hours are up. A run of a strategy that an earlier run of yours already took through the trial starts `LIVE`, unless you started it with `sandbox: true`.' state: type: string description: 'The run''s health right now: `STARTING` (no runner has reported on it yet), `RUNNING`, `LAGGING` (behind the market data, usually while catching up; clears by itself), `HUNG` (stuck inside one strategy call for longer than allowed; clears when it returns), `DEGRADED` (its independent executions produced different signals), `FAILED` (refused, could not start or failed while running; `reason` says why) or `STOPPED`. `LAGGING`, `HUNG` and `DEGRADED` come and go on a running run. The set may grow: read an unknown value as a running run with something to look at. See the Live execution guide.' desired: type: string enum: [RUNNING, STOPPED] description: What you last asked for. `state` can lag this briefly after `DELETE`. reason: type: string description: | Why the run stopped or failed, when there is something to say; absent otherwise. It is never a stack trace or an internal message. Either `resource: ...` (the platform stopped the run for exceeding its resource allowance; the text says which limit) or one of a fixed set of sentences for a `FAILED` run: the strategy cannot consume the source type the run was started with, the run's definition was refused, the run could not start after several attempts, the strategy failed while processing data, the run lost its data feed, or the generic `The run failed.`. The set may grow: read an unrecognised sentence as a failure and do not parse it. A `FAILED` run usually stays `desired: RUNNING` until you stop it, and counts as active (`409` on a new start, and toward your live-run limit) until then; one that can never run because its strategy cannot consume its source type is stopped by the platform itself (`desired: STOPPED`), so it holds no place. sources: type: array items: $ref: '#/components/schemas/LiveSource' createdAtMs: type: integer format: int64 description: Epoch milliseconds this run was started. startedAtMs: type: integer format: int64 description: Epoch milliseconds. LiveListResponse: type: object required: [runs] properties: runs: type: array items: $ref: '#/components/schemas/LiveRunSummary' _links: $ref: '#/components/schemas/PublicLiveListLinks' PublicLiveRun: type: object required: [runId, sources, state, createdAtMs] description: A run as it appears in `GET /live/public` — never reveals who owns it or which strategy it runs. properties: runId: type: string name: type: string description: type: string sources: type: array items: $ref: '#/components/schemas/LiveSource' state: type: string createdAtMs: type: integer format: int64 PublicLiveListResponse: type: object required: [runs] properties: runs: type: array items: $ref: '#/components/schemas/PublicLiveRun' _links: $ref: '#/components/schemas/PublicLiveListLinks' PublicLiveListLinks: type: object description: Present only when another page exists. properties: next: $ref: '#/components/schemas/PublicLiveNextLink' PublicLiveNextLink: type: object properties: href: type: string LiveSignalPage: type: object required: [signals] description: One page of a run's recorded signals, oldest first. properties: signals: type: array items: $ref: '#/components/schemas/LiveSignal' availableSinceMs: type: integer format: int64 description: The oldest moment this run's signals can still be read from. Absent when the run has produced nothing yet. It moves forward over time as older signals are discarded, so a `sinceMs` earlier than this is served from here instead. _links: $ref: '#/components/schemas/PublicLiveListLinks' LivePaper: type: object required: [runId, stage, accounts] properties: runId: type: string stage: type: string enum: [SANDBOX, LIVE] accounts: type: array items: $ref: '#/components/schemas/LivePaperAccount' LivePaperAccount: type: object required: [currency, initialFunding, equity, realisedPnl, trades, gaps, openPositions] description: One simulated account — one per quote currency the run trades. properties: currency: type: string description: The account's quote currency; every amount below is in it. initialFunding: type: number format: double equity: type: number format: double description: Latest recorded equity — see `equityKind`. equityAtMs: type: integer format: int64 description: Market time of that value. Absent while the account holds its starting capital. equityKind: type: string enum: [equity, mark] description: '`equity` at a closed trade, `mark` at a periodic mark-to-market (includes open positions at market price).' realisedPnl: type: number format: double description: Sum of the PnL of the closed trades. trades: type: integer format: int64 description: Closed trades. gaps: type: integer description: Times open positions were lost because the run was restarted with them open. openPositions: type: array items: $ref: '#/components/schemas/LivePaperPosition' kpi: $ref: '#/components/schemas/LivePaperKpi' LivePaperPosition: type: object required: [instrument, base, cost] properties: instrument: type: string example: BTC/USDT base: type: number format: double description: Amount held, in the base asset. cost: type: number format: double description: What it cost, in the account's currency. LivePaperKpi: type: object description: The same KPIs a backtest reports, over the trades closed so far. Absent until the first closed trade is recorded. properties: totalTrades: type: integer format: int64 winCount: type: integer format: int64 lossCount: type: integer format: int64 winRate: type: number format: double description: Ratio (0.5 = half the trades won). pnlTotal: type: number format: double pnlTotalPercent: type: number format: double description: Percent of `initialFunding` (0-100 scale). sharpeRatio: type: ['number', 'null'] format: double sortinoRatio: type: ['number', 'null'] format: double cagr: type: ['number', 'null'] format: double description: Ratio (0.15 for 15%). maxDrawdown: type: number format: double maxDrawdownPercent: type: number format: double description: Percent (0-100 scale). LivePaperEquityPage: type: object required: [points] properties: points: type: array items: $ref: '#/components/schemas/LivePaperEquityPoint' _links: type: object properties: next: type: object properties: href: type: string LivePaperEquityPoint: type: object required: [currency, kind, eventTsMs] properties: currency: type: string kind: type: string enum: [equity, mark, gap] eventTsMs: type: integer format: int64 description: Market time of the point. equity: type: number format: double description: Absent on a `gap`. LiveSignal: type: object required: [v, signalId, runId, stage, type, eventTsMs, emittedAtMs, instrument, digest] description: One signal, in the same shape the real-time signal channel delivers. properties: v: type: integer description: Envelope schema version. signalId: type: string description: Stable id for this exact signal — dedupe on it across reconnects or overlapping reads. runId: type: string stage: type: string enum: [sandbox, live] description: The stage the run was in when this signal was produced. paramsVersion: type: integer description: The parameter set in force when this signal was produced. type: type: string enum: [hint, info, marker, command, paper] description: '`paper` items appear only for a run whose `paper.output` is `mix`; they are not relayed over the WebSocket channel.' kind: type: ['string', 'null'] description: '`BUY`/`SELL` for a `hint`, the command name for a `command`; for `paper`, what the item is: `fill`, `trade`, `equity`, `mark`, `kpi` or `gap`. Absent otherwise.' eventTsMs: type: integer format: int64 description: Market time the signal was produced. emittedAtMs: type: integer format: int64 description: Time it was published — always at or after `eventTsMs`. instrument: anyOf: - $ref: '#/components/schemas/LiveSignalInstrument' - type: 'null' description: The instrument the signal is about. `null` only for a `paper` item about a whole account (`equity`, `mark`, `kpi`), whose `data.currency` names the account. order: $ref: '#/components/schemas/LiveSignalOrder' data: type: object additionalProperties: true description: 'The signal''s own free-form payload, what the strategy put there with `signal.set(...)`. Whoever may read the run may read it, so on a `public` run it is public. A signal whose `data` is over 8 KiB (8,192 bytes of its JSON) is not pushed on the WebSocket channel, and `GET /live/{runId}/signals` returns it whole.' regenerated: type: boolean description: '`true` only for a signal republished to fill a gap in the record.' digest: type: string description: Content hash, for checking that two independent deliveries of the same signal agree. LiveSignalInstrument: type: object properties: exchange: type: string segment: type: string symbol: type: string description: Slashed form, e.g. `BTC/USDT`. LiveSignalOrder: type: ['object', 'null'] description: Present only for a `hint`. properties: orderKind: type: string price: type: ['string', 'null'] amount: type: ['string', 'null'] stopPrice: type: ['string', 'null'] trailPct: type: ['string', 'null'] LiveConnectionToken: type: object required: [token, expiresAtMs] properties: token: type: string description: Pass as the `token` field of the WebSocket `connect` command — see the "Live execution" guide. expiresAtMs: type: integer format: int64 LiveParamsUpdateResult: type: object required: [runId, paramsVersion, effectiveAtMs] properties: runId: type: string paramsVersion: type: integer effectiveAtMs: type: integer format: int64 description: Epoch milliseconds — the earliest moment the new values are guaranteed to be in effect. LiveCommandResult: type: object required: [runId, commandId, effectiveAtMs] properties: runId: type: string commandId: type: string description: This command's own id, generated fresh for this call. A retried request is a second, distinct command — this endpoint takes no idempotency key. effectiveAtMs: type: integer format: int64 description: Epoch milliseconds — the market position every execution applies this command at. 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. - name: Live Execution description: |- The **live execution** endpoints run your strategy continuously against a live market feed instead of a fixed historical window, publishing its signals as they happen. A run always starts in a short sandbox trial before promotion to live, unless an earlier run of the same compiled strategy has already been through it. These endpoints cover the run's lifecycle (start, inspect, stop, change visibility) and its runtime parameters. Consuming the run's own signal stream and updating parameters over a live connection instead of polling is a WebSocket protocol built on top of these same endpoints — see **[Live execution](docs/live.md)** for the full flow: minting a connection token (`POST /live/token`), the signal channel, and the `live.params` RPC. A run can also be paper-traded — its signals executed in simulation, with equity and KPIs — see **[Paper trading](docs/live_paper.md)**. **Key functionalities**: - Start, inspect, and stop a live run for one of your strategies. - Browse other users' runs they have made public. - Change a run's own visibility, name, or description. - Update a running strategy's parameters without restarting it. - Mint a token to receive a run's signals and updates in real time over WebSocket. externalDocs: description: Find out more about QTSurfer API url: 'https://qtsurfer.com/developers'