openapi: 3.1.0
info:
  title: QTSurfer API
  summary: QTSurfer backend services API
  version: 0.128.20
  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
        <token>` 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 comments (`//` or `/* */`) 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.
      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 always 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`.

        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.

        `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, 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.
          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("<key>")` in Java, `$command.<key>` 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 (`*/USDT` for any
            base against that quote, `BTC/*` for that base against 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&currency=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
        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:<user>:<strategyId>`. 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 <token>` 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: Instrument symbols, or `["*"]` for every instrument the exchange/segment offers (tier-gated).
    StartLiveRequest:
      type: object
      required: [sources]
      properties:
        sources:
          type: array
          minItems: 1
          maxItems: 1
          items:
            $ref: '#/components/schemas/LiveSource'
        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 promoted to `live`. 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.
        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 always 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.'
        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. `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 always 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.'
        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&trade; 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.

      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'
