openapi: 3.0.3
info:
  title: 0 DTE SPX Trading Simulator API
  description: |
    Paper-trade 0 DTE SPX options on any historical trading day. Place orders, track
    positions and Greeks, and monitor P&L as the simulation clock advances through
    the session.

    Access has two levels:
    - **public** (no auth) — market data for the most recent completed session only, at 30-second resolution
    - **registered** (free account) — everything: the full simulator, unlimited simulations, 1-second data, all dates

    Errors are returned as plain-text bodies with the appropriate HTTP status code.
  version: '1.0.0'

servers:
  - url: https://api.0dtespx.com
    description: Production

tags:
  - name: health
  - name: docs
  - name: auth
  - name: user
  - name: simulations
  - name: orders
  - name: positions
  - name: transactions
  - name: live
  - name: market-data
  - name: strategies
  - name: websocket

security:
  - bearerToken: []
  - {}

paths:
  /health:
    get:
      tags: [health]
      summary: Liveness probe
      security: []
      responses:
        '200':
          description: Service alive

  /README.md:
    get:
      tags: [docs]
      summary: Project README (redirect)
      description: |
        Permanently redirects (301) to the API overview in the web
        documentation at `https://www.0dtespx.com/docs/api/overview`,
        where the domain model, order lifecycle, fill rules, settlement,
        fees, and market-data feeds are documented.
      security: []
      responses:
        '301':
          description: Moved permanently to the web docs
          headers:
            Location:
              schema:
                type: string
              description: https://www.0dtespx.com/docs/api/overview

  /TUTORIAL.md:
    get:
      tags: [docs]
      summary: End-to-end trading tutorial (redirect)
      description: |
        Permanently redirects (301) to the API walkthrough in the web
        documentation at `https://www.0dtespx.com/docs/api/walkthrough` —
        a hands-on walkthrough covering account creation, simulation
        setup, order placement (market / limit / stop), positions and
        Greeks, time advancement, settlement, and the WebSocket feed,
        with copy-pasteable `curl` examples.
      security: []
      responses:
        '301':
          description: Moved permanently to the web docs
          headers:
            Location:
              schema:
                type: string
              description: https://www.0dtespx.com/docs/api/walkthrough

  /auth/check-email:
    post:
      tags: [auth]
      summary: Check if an email address has a registered account
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CheckEmailRequest'
      responses:
        '200':
          description: Account exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckEmailResponse'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: No account for this email
        '429':
          description: Per-IP rate limit exceeded; retry after `Retry-After` seconds

  /auth/verify-email:
    post:
      tags: [auth]
      summary: Send a 6-digit email verification code
      description: |
        Sends a 6-digit code to the address. Code expires after 15 minutes and is
        locked after 5 failed submissions. Rate-limited to one request per 60 seconds.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VerifyEmailRequest'
      responses:
        '204':
          description: Code sent
        '400':
          $ref: '#/components/responses/PlainTextError'
        '429':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /auth/register:
    post:
      tags: [auth]
      summary: Create a new user account
      description: |
        Verification code (from `/auth/verify-email`) is required. Codes are
        single-use and are consumed by registration, so the response includes
        the bearer token of the account's first session — no follow-up
        `POST /auth/sessions` call is needed (and re-submitting the same code
        there would fail).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
      responses:
        '201':
          description: Account created; session token returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostSessionResponse'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '429':
          description: Per-IP rate limit exceeded; retry after `Retry-After` seconds
        '500':
          $ref: '#/components/responses/PlainTextError'

  /auth/forgot-password:
    post:
      tags: [auth]
      summary: Send a password-reset email
      description: Rate-limited to one request per 60 seconds per user.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ForgotPasswordRequest'
      responses:
        '204':
          description: Reset email sent
        '404':
          $ref: '#/components/responses/PlainTextError'
        '429':
          $ref: '#/components/responses/PlainTextError'

  /auth/reset-password:
    post:
      tags: [auth]
      summary: Reset password using a reset-token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResetPasswordRequest'
      responses:
        '204':
          description: Password updated
        '400':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
        '429':
          description: Per-IP rate limit exceeded; retry after `Retry-After` seconds

  /auth/sessions:
    post:
      tags: [auth]
      summary: Log in (create a session token)
      description: |
        Authenticate with email + password, or with email + verification code (one-time
        login). Returns the bearer token to use in `Authorization` for all
        authenticated endpoints.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostSessionRequest'
      responses:
        '201':
          description: Session created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostSessionResponse'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '429':
          description: Per-IP rate limit exceeded; retry after `Retry-After` seconds
        '500':
          $ref: '#/components/responses/PlainTextError'
    delete:
      tags: [auth]
      summary: Log out (delete the current session)
      responses:
        '204':
          description: Session revoked
        '401':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /user:
    get:
      tags: [user]
      summary: Get current user's profile
      responses:
        '200':
          description: Profile
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
    patch:
      tags: [user]
      summary: Update user profile
      description: |
        Update one or more profile fields. Email and password changes each require
        a fresh verification code (sent via `/auth/verify-email`); the two cannot
        be changed in the same call. Changing the password invalidates all other
        sessions for the user.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchUserRequest'
      responses:
        '204':
          description: Updated
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '403':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /simulations:
    get:
      tags: [simulations]
      summary: List the current user's simulations
      responses:
        '200':
          description: Simulation list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Simulation'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'
    post:
      tags: [simulations]
      summary: Create a new simulation
      description: |
        - `starting_capital` must be in `[1000, 10000000]`.
        - One simulation per date per user.
        - The simulation begins at market open; advance time using `PATCH`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostSimulationRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Simulation'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '403':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /simulations/{id}:
    parameters:
      - $ref: '#/components/parameters/SimulationId'
    get:
      tags: [simulations]
      summary: Get a simulation
      responses:
        '200':
          description: Simulation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Simulation'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'
    patch:
      tags: [simulations]
      summary: Advance (or rewind) the simulation clock
      description: |
        Sets the simulation's current time. Must be within `[start_time, end_time]`.
        On every change, financials are recomputed for the new timestamp; the
        underlying transactions and orders are not modified.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PatchSimulationRequest'
      responses:
        '200':
          description: Updated simulation
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Simulation'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'
    delete:
      tags: [simulations]
      summary: Delete a simulation and all its data
      responses:
        '204':
          description: Deleted
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /simulations/{id}/history:
    parameters:
      - $ref: '#/components/parameters/SimulationId'
    get:
      tags: [simulations]
      summary: Per-second financial history for the simulation
      description: |
        Returns financial snapshots from session start up to the most recent
        recorded point. Sample rate is 1 second.
      responses:
        '200':
          description: History entries
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SimulationHistoryEntry'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /simulations/{id}/orders:
    parameters:
      - $ref: '#/components/parameters/SimulationId'
    get:
      tags: [orders]
      summary: List orders for a simulation
      description: Order `status` is computed against the current simulation time.
      responses:
        '200':
          description: Order list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Order'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'
    post:
      tags: [orders]
      summary: Place an order
      description: |
        Creates a new order in the simulation. Validation order:

        1. Order type, leg actions, instruments parsed.
        2. Limit / stop fields required (`price`, `stop_trigger`, `price_effect`).
        3. SPX option tick rules (single-leg ≤ \$3 → \$0.05; > \$3 → \$0.10; multi-leg → \$0.05).
        4. Limit `price` cannot exceed the order's structural maximum profit
           (e.g. a $5-wide vertical cannot have a limit price above $5.00).
           Skipped when the order's max profit is unbounded (naked long calls,
           ratio backspreads, calendars).
        5. Market data must exist for every leg at sim time.
        6. Stop orders cannot be immediately marketable.
        7. Buying-power check (includes existing pending orders).

        After creation, the order is filled if marketable, otherwise its
        `fill_datetime` is pre-computed by scanning the rest of the session.

        The response includes a `buying-power-effect` block describing the
        projected impact of this order on maintenance margin and available
        buying power.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostOrderRequest'
      responses:
        '201':
          description: Order created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /simulations/{id}/orders/dry-run:
    parameters:
      - $ref: '#/components/parameters/SimulationId'
    post:
      tags: [orders]
      summary: Dry-run an order
      description: |
        Validates an order and returns the same response shape `POST /simulations/{id}/orders`
        would produce — including `buying-power-effect` and projected `fill_price` /
        `fill_datetime` / `fees` — without persisting the order, generating
        transactions, or recomputing history. Useful for pre-trade confirmation
        flows where callers want to preview the impact before committing.

        All validation runs identically to the real endpoint and surfaces the same
        `400` errors (order type, leg shape, price / stop fields, SPX tick rules,
        max-profit cap, market-data availability, stop-immediacy, buying-power).

        Differences from the real endpoint:

        - Returns `200 OK` (no resource created).
        - `id` is synthetic — no order with this id exists in the simulation.
        - The `transactions` array is omitted on filled orders, since no
          transactions are generated.
        - `fees` is the schedule-based estimate; for ordinary orders it matches
          the realised fees the real endpoint would return.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostOrderRequest'
      responses:
        '200':
          description: Simulated order (not persisted)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /simulations/{id}/orders/{orderId}:
    parameters:
      - $ref: '#/components/parameters/SimulationId'
      - $ref: '#/components/parameters/OrderId'
    get:
      tags: [orders]
      summary: Get a single order
      responses:
        '200':
          description: Order
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'
    delete:
      tags: [orders]
      summary: Delete an order
      description: |
        Removes the order, its transactions, and the settlement transactions it
        affected. History is recomputed.
      responses:
        '204':
          description: Deleted
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /simulations/{id}/positions:
    parameters:
      - $ref: '#/components/parameters/SimulationId'
    get:
      tags: [positions]
      summary: Positions snapshot at the simulation's current time
      description: |
        Computed on the fly: replays all transactions visible at sim time and
        applies live market data to compute Greeks, marks, and P&L.
      responses:
        '200':
          description: Positions
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Position'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /simulations/{id}/transactions:
    parameters:
      - $ref: '#/components/parameters/SimulationId'
    get:
      tags: [transactions]
      summary: Transactions visible at the simulation's current time
      description: |
        Returns only transactions whose `datetime <= sim.time`. Includes the
        initial deposit, leg fills from filled orders, and any settlement
        transactions (expiration / exercise / assignment) at 16:00 ET.
      responses:
        '200':
          description: Transactions
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Transaction'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /live:
    post:
      tags: [live]
      summary: Start the user's live simulation for today
      description: |
        Live simulations track the in-progress trading session in real time.
        The system validates that the market is open, that the current time is
        at or after the session's `data-start-time`, that the recorder's latest
        tick is fresh (≤30s old), and that no live simulation is already
        active for today.

        Mutating live endpoints route through an internal queue and return
        `503` if the live trading subsystem is temporarily unavailable; the
        client may safely retry. Idempotency is supported via the
        `Idempotency-Key` request header.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostLiveRequest'
      responses:
        '201':
          description: Live simulation created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LiveSimulation'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '403':
          $ref: '#/components/responses/PlainTextError'
        '503':
          $ref: '#/components/responses/PlainTextError'
    get:
      tags: [live]
      summary: Get the user's current live simulation
      responses:
        '200':
          description: Live simulation, or `null` when none is active
          content:
            application/json:
              schema:
                oneOf:
                  - type: 'null'
                    description: No active live simulation
                  - $ref: '#/components/schemas/LiveSimulation'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'
    delete:
      tags: [live]
      summary: Delete the user's current live simulation
      description: |
        Cascades the delete to live orders and transactions. The user
        may then create a new live simulation for the same day.

        When the live trading subsystem is temporarily unreachable the request
        is accepted with `202` and the deletion completes in the background;
        until then `GET /live` may still return the simulation.
      responses:
        '204':
          description: Deleted
        '202':
          description: Deletion accepted; completing in the background
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /live/orders:
    post:
      tags: [live]
      summary: Place a new live order
      description: |
        Place a market, limit, or stop order against the user's current live
        simulation. The order is enqueued for the live exchange and the API
        waits up to 5 seconds for the result.

        Mutating order endpoints accept an optional `Idempotency-Key`
        request header (RFC 9462). Duplicates within the retention window
        return the prior request's mapped response instead of placing a new
        order.

        Live orders never time-travel — `status` is the persisted lifecycle
        state and stays terminal once set. Possible statuses:

        - `pending` — accepted, awaiting a matching tick
        - `filled` — executed; the response includes the per-leg
          `transactions` array
        - `canceled` — user cancellation succeeded
        - `expired` — auto-expired at session close
        - `rejected` — failed the buying-power recheck at fill time;
          `rejection_reason` carries the reason

        Limit `price` cannot exceed the order's structural maximum profit
        (e.g. a $5-wide vertical cannot have a limit price above $5.00).
        Skipped when the order's max profit is unbounded.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostOrderRequest'
      responses:
        '201':
          description: Order placed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LiveOrder'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '409':
          $ref: '#/components/responses/PlainTextError'
        '503':
          $ref: '#/components/responses/PlainTextError'
    get:
      tags: [live]
      summary: List orders for the user's current live simulation
      responses:
        '200':
          description: Orders
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/LiveOrder'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: No active live simulation
        '500':
          $ref: '#/components/responses/PlainTextError'

  /live/orders/dry-run:
    post:
      tags: [live]
      summary: Validate an order and project buying power without persisting
      description: |
        Same response shape as `POST /live/orders` (minus the `transactions`
        array). The response is **advisory** — actual placement may differ
        if state changes between dry-run and place (a tick lands, a sibling
        order fills, etc.). Clients should treat this as a UX preview, not
        a commitment.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostOrderRequest'
      responses:
        '200':
          description: Validation result and buying-power projection
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LiveOrder'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'

  /live/orders/{id}:
    get:
      tags: [live]
      summary: Get a single live order
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Order
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LiveOrder'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: Order not found
    put:
      tags: [live]
      summary: Atomically cancel an existing pending limit order and place a replacement
      description: |
        Replace a pending **limit** order with a new price and/or price
        effect. The cancel and the new placement run in a single exchange
        transaction; observers never see both orders simultaneously
        pending. Returns the new order's JSON.

        Only `price` and `price_effect` may change — type, legs and
        underlying are immutable across a replacement. Replacing a
        non-`limit` order returns 400; replacing an order that is no
        longer pending returns 409.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PutLiveOrderRequest'
      responses:
        '200':
          description: Replaced
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LiveOrder'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: Target order not found
        '409':
          $ref: '#/components/responses/PlainTextError'
        '503':
          $ref: '#/components/responses/PlainTextError'
    delete:
      tags: [live]
      summary: Cancel a pending live order
      description: |
        Returns 409 if the order is no longer pending (already filled,
        canceled, expired, or rejected).
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '204':
          description: Cancellation accepted
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: Order not found
        '409':
          $ref: '#/components/responses/PlainTextError'
        '503':
          $ref: '#/components/responses/PlainTextError'

  /live/positions:
    get:
      tags: [live]
      summary: Current positions with details
      description: |
        Position details (NLV, unrealized P&L, total P&L, greeks, per-leg
        market value) are computed against the most recent option-chain
        snapshot at request time — the same snapshot the WebSocket pushes.
        The REST response is the seed value for the client's own
        per-second recompute over the WebSocket stream.
      responses:
        '200':
          description: Positions
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Position'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: No active live simulation
        '500':
          $ref: '#/components/responses/PlainTextError'

  /live/history:
    get:
      tags: [live]
      summary: Per-second financial history for the user's current live simulation
      description: |
        Returns financial snapshots from session start up to the most recent
        recorded point — the series used to draw the P&L line on the live chart.
        Sample rate is 1 second. Returns an empty array when there is no active
        live simulation.
      responses:
        '200':
          description: History entries
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SimulationHistoryEntry'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /live/transactions:
    get:
      tags: [live]
      summary: List transactions for the user's current live simulation
      responses:
        '200':
          description: Transactions
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Transaction'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: No active live simulation
        '500':
          $ref: '#/components/responses/PlainTextError'

  /market-data/sessions:
    get:
      tags: [market-data]
      summary: All known market sessions, keyed by date
      description: |
        Public endpoint. Sessions a user cannot access are flagged `restricted: true`.
      security: []
      responses:
        '200':
          description: Sessions map
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MarketSessionsResponse'

  /market-data/strikes/{date}:
    get:
      tags: [market-data]
      summary: Available SPX option strikes for a trading date
      description: |
        Sorted ascending. Rate-limit cost: 5 credits.
      parameters:
        - $ref: '#/components/parameters/MarketDataDate'
      responses:
        '200':
          description: Strikes
          content:
            application/json:
              schema:
                type: array
                items:
                  type: integer
                  example: 5000
        '204':
          description: No strikes for this date
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '403':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: Date is not a trading session
        '429':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /market-data/historical/{date}:
    get:
      tags: [market-data]
      summary: Historical aggregate market data for a session
      description: |
        Public for the most recent completed session and the current (in-progress)
        session. Authenticated users get all sessions.

        Sample rate: authenticated = 1s, public = 30s. The current
        (in-progress) session returns 1s data to everyone until it closes; once
        it ends, today is served at the caller's rate like any completed
        session.

        Selectable series via `?series=` (comma-separated):
        - `spx`, `vix`
        - `spxExpectedMove`
        - `spxOTMBids`
        - `spxExtrinsic`

        Default: `spx,spxExpectedMove`.

        Rate-limit cost: 10 credits.
      security:
        - bearerToken: []
        - {}
      parameters:
        - $ref: '#/components/parameters/MarketDataDate'
        - in: query
          name: series
          schema:
            type: string
            example: 'spx,spxExpectedMove'
          description: Comma-separated list of data series to include.
      responses:
        '200':
          description: Aggregate data (an empty array when the session has no rows yet)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/HistoricalDataPoint'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '403':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: Session does not exist
        '429':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /market-data/option-chain-snapshots/{timestamp}:
    get:
      tags: [market-data]
      summary: SPX option chain snapshot at a single timestamp
      description: |
        `timestamp` format: `2006-01-02T15:04:05` (UTC timezone).

        Rate-limit cost: 10 credits.
      parameters:
        - $ref: '#/components/parameters/SnapshotTimestamp'
      responses:
        '200':
          description: Option chain map keyed `call_<strike>` / `put_<strike>`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SnapshotMap'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '403':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: No snapshot at this timestamp
        '429':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /market-data/option-chain-snapshots/{startTime}/{endTime}:
    get:
      tags: [market-data]
      summary: SPX option chain snapshots over a time range
      description: |
        Up to 30 snapshots are returned. Maximum range = `30 * interval` seconds
        (interval is 1s for authenticated callers, 30s public).

        Rate-limit cost: 5 credits per snapshot returned. The
        snapshot count is computed from `endTime - startTime` and the caller's
        interval, capped at 30, so cost ranges from 5 (single snapshot) to
        150 (full 30-snapshot range).
      parameters:
        - in: path
          name: startTime
          required: true
          schema:
            type: string
            example: '2025-01-15T09:30:00'
        - in: path
          name: endTime
          required: true
          schema:
            type: string
            example: '2025-01-15T09:30:30'
      responses:
        '200':
          description: Snapshots array
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SnapshotRangeEntry'
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '403':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: No snapshots in this range
        '429':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /market-data/average-expected-move:
    get:
      tags: [market-data]
      summary: Average expected move curve (per second of session)
      description: |
        Public. Cached by browser for 24 hours.
      security: []
      responses:
        '200':
          description: |
            Series ordered by second of session ascending. Each point carries
            its own `second_of_session`, so values map to time by that field
            (the series does not start at second 0).
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [second_of_session, avg_pct_of_open]
                  properties:
                    second_of_session:
                      type: integer
                      description: Seconds elapsed since the session open
                      example: 30
                    avg_pct_of_open:
                      type: string
                      description: Average expected move as a fraction of the session-open value
                      example: '0.9912'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /strategies:
    get:
      tags: [strategies]
      summary: List the current user's saved strategies
      description: |
        Authenticated. The list is the leaderboard: each saved
        strategy carries its headline metrics (`metrics`, the
        fold net of the **default** fee schedule over the full history),
        coverage counters, and staleness (`new_sessions_available`). Ordered
        by `updated_at` desc. The Starlark source is never included.
      responses:
        '200':
          description: Saved strategies
          content:
            application/json:
              schema:
                type: object
                required: [strategies]
                properties:
                  strategies:
                    type: array
                    items:
                      $ref: '#/components/schemas/SavedStrategyListItem'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'
    post:
      tags: [strategies]
      summary: Save (publish) a builder strategy
      description: |
        Authenticated. Saving is **publishing**: the supplied form
        `config` is validated and generated, the shared **immutable** strategy
        is upserted by its source-code hash, and your personal link to it is
        created (or resurrected, if you previously removed the same
        configuration — same `id` comes back). The strategy **adopts the
        content-addressed backtest** already running/done for that hash — a
        config you just previewed publishes with its results intact, no
        recompute.

        Identical configurations are one shared strategy: if another user
        already saved (or previewed) the exact same config, you reuse their
        results instantly.

        Saving never fails on the concurrency cap: if a coverage run is still
        needed and you are at your 3-active-runs cap, the run is parked and
        starts automatically when one of your runs finishes.

        A malformed `config` returns `400 invalid_config` with a `fields` map
        keyed by dotted config path; nothing is created.

        New strategies are **private** by default. Pass `privacy: "public"` to
        publish immediately, or set it later via `PATCH /strategies/{id}`. Public
        is **unlisted, not discoverable** — there is no directory of public
        strategies and the id (a random UUID) can't be guessed — so a public
        strategy is reachable only by someone you give the link to; once they
        have it, any user or unauthenticated visitor can view and clone it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [config]
              properties:
                config:
                  $ref: '#/components/schemas/BuilderConfig'
                privacy:
                  type: string
                  enum: [private, public]
                  default: private
                  description: |
                    Visibility of your link to the strategy. Omitted ⇒
                    `private`. `public` makes it viewable and clonable by anyone
                    **with the link** (it is unlisted, never discoverable).
      responses:
        '201':
          description: Strategy saved (link created or resurrected)
          content:
            application/json:
              schema:
                type: object
                required: [id, source_hash, privacy, snapshot]
                properties:
                  id:
                    type: string
                    format: uuid
                    description: Your saved-strategy id.
                  source_hash:
                    type: string
                    description: The content address of the strategy's generated source.
                  privacy:
                    type: string
                    enum: [private, public]
                    description: The saved link's visibility.
                  snapshot:
                    $ref: '#/components/schemas/ResultsSnapshot'
        '400':
          description: Invalid builder config
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BuilderConfigError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /strategies/preview:
    post:
      tags: [strategies]
      summary: Preview a builder config (a persisted, shared backtest)
      description: |
        Authenticated. The builder's per-change action. There is
        **no ephemeral dry-run**: every distinct configuration is a real,
        persisted backtest keyed by the hash of its generated source, shared
        across users. Previewing ensures that backtest exists, starts (or
        resumes) its coverage run when sessions are missing, registers your
        active interest in it, and returns the current
        fee-overlaid results snapshot — for an already-computed configuration
        this is an instant cache hit.

        `abandon_hash` (the previous preview's `source_hash`) tells the server
        you stopped caring about that configuration; its run is stopped at the
        next session boundary if nobody else is watching and it was never
        saved. Completed sessions are kept — returning to that configuration
        resumes where it left off.

        Results always cover **every recorded session** at a fixed **$100,000
        per-session capital**. Fees are applied at read time from your fee
        schedule.

        Returns `429 too_many_active_backtests` when you already have 3 runs
        executing; retry with backoff (a steady builder session never trips
        this — each change abandons the previous run first).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [config]
              properties:
                config:
                  $ref: '#/components/schemas/BuilderConfig'
                abandon_hash:
                  type: string
                  description: source_hash of the previous preview to abandon.
      responses:
        '200':
          description: Current results snapshot + generated artifacts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PreviewResponse'
        '400':
          description: Invalid builder config (nothing created)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BuilderConfigError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '429':
          $ref: '#/components/responses/TooManyActiveRuns'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /strategies/preview/{source_hash}/results:
    get:
      tags: [strategies]
      summary: Read a preview's results (pre-save read path)
      description: |
        Authenticated. The builder's results read path: the day list and
        folded summary, net of your fee schedule. While a preview runs, the
        live updates arrive over the WebSocket `backtest_events` channel — each
        frame carries this same fee-overlaid snapshot under `data.snapshot`, so
        the client renders straight from it. This endpoint is the source of
        truth for the initial load and reconnect/fallback, and calling it also
        refreshes your interest heartbeat, keeping an unsaved preview's run
        alive while the builder is open.

        Authorized by your live interest in the hash (from a recent preview)
        or a saved strategy of yours with this source.
      parameters:
        - in: path
          name: source_hash
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Fee-overlaid results snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ResultsSnapshot'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: No active preview or saved strategy for this hash
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimpleErrorEnvelope'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /strategies/preview/abandon:
    post:
      tags: [strategies]
      summary: Abandon a preview
      description: |
        Authenticated. The explicit "I'm done with this configuration" signal
        (sent on leaving the builder). Drops your interest; the run stops at
        the next session boundary iff nobody else is watching and the
        configuration was never saved. Best-effort — interest also expires on
        its own 15 minutes after your last results fetch. Always succeeds.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [source_hash]
              properties:
                source_hash:
                  type: string
      responses:
        '204':
          description: Interest dropped
        '401':
          $ref: '#/components/responses/PlainTextError'

  /strategies/{id}:
    get:
      tags: [strategies]
      summary: Get one saved strategy with its results
      description: |
        The immutable logic (config, generated description, summary, risks) plus
        the results snapshot: net of the viewer's fee schedule (the default
        schedule for unauthenticated visitors), with coverage/staleness
        (`new_sessions_available`), and the labeled window.

        **Auth is optional.** A strategy whose `privacy` is `public` is readable
        by any user and by unauthenticated visitors **who have the link** —
        public strategies are unlisted and never discoverable (no directory, and
        the id is an unguessable UUID), so the link must be shared with them; a
        `private` strategy is readable only by its owner. The response includes
        `privacy` and `is_owner` so a client can render owner-only controls. A
        private strategy you don't own returns `404` (indistinguishable from a
        missing one — its existence is not revealed). Viewing a strategy as an
        authenticated user keeps its in-flight run's interest fresh.
      parameters:
        - $ref: '#/components/parameters/StrategyId'
      responses:
        '200':
          description: Strategy + results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StrategyDetail'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimpleErrorEnvelope'
        '500':
          $ref: '#/components/responses/PlainTextError'
    patch:
      tags: [strategies]
      summary: Update per-user metadata
      description: |
        Authenticated, owner only. Strategies are immutable — only your personal
        metadata is mutable: a `description` override and the `privacy`
        visibility flag. Setting `privacy: "public"` publishes the strategy so
        anyone **with the link** can view and clone it (it stays unlisted and
        undiscoverable — share the link to actually distribute it);
        `"private"` unpublishes it. Each field is applied only when present. To
        change the logic, clone: save a new configuration.
      parameters:
        - $ref: '#/components/parameters/StrategyId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                description:
                  type: string
                  nullable: true
                privacy:
                  type: string
                  enum: [private, public]
                  description: New visibility for your link to the strategy.
      responses:
        '204':
          description: Updated
        '400':
          $ref: '#/components/responses/PlainTextError'
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimpleErrorEnvelope'
    delete:
      tags: [strategies]
      summary: Remove a saved strategy
      description: |
        Authenticated. Removes the strategy from **your** list (soft unlink).
        The shared strategy and its results survive for other users of the
        same configuration; re-saving the exact same configuration restores
        your entry (same id).
      parameters:
        - $ref: '#/components/parameters/StrategyId'
      responses:
        '204':
          description: Removed
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimpleErrorEnvelope'

  /strategies/{id}/results/days:
    get:
      tags: [strategies]
      summary: List a strategy's per-session results
      description: |
        Optional auth — public strategies are readable by anyone, private ones
        only by their owner. The day list (date, status, gross P&L, fees under
        the viewer's schedule, net P&L, order counts) plus the labeled window
        bounds.
      parameters:
        - $ref: '#/components/parameters/StrategyId'
      responses:
        '200':
          description: Day list
          content:
            application/json:
              schema:
                type: object
                properties:
                  days:
                    type: array
                    items:
                      $ref: '#/components/schemas/DaySlice'
                  window_from:
                    type: string
                    format: date
                  window_to:
                    type: string
                    format: date
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimpleErrorEnvelope'

  /strategies/{id}/results/days.csv:
    get:
      tags: [strategies]
      summary: Download a strategy's per-session results as CSV
      description: |
        Optional auth — public strategies are downloadable by anyone, private
        ones only by their owner. The same fee-overlaid day list as
        `GET /strategies/{id}/results/days`, rendered as a CSV attachment.
        Columns: `date`, `status`, `gross_pnl`, `fees` (viewer's schedule),
        `net_pnl`, `num_orders`, `num_filled`, `intraday_max_dd` (the
        engine's fee-free intraday max drawdown), `spx_close`,
        `error_message`, `halt_reason`.
      parameters:
        - $ref: '#/components/parameters/StrategyId'
      responses:
        '200':
          description: 'CSV attachment (`Content-Disposition: attachment`)'
          content:
            text/csv:
              schema:
                type: string
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimpleErrorEnvelope'

  /strategies/{id}/results/days/{date}:
    get:
      tags: [strategies]
      summary: Session drill-in (recomputed on demand)
      description: |
        Optional auth (public strategies open to anyone, private to the owner);
        rate-limited like other heavy reads for authenticated callers. The
        per-session detail — orders, transactions, decision log, and the
        intraday equity curve — is **not stored**: it is recomputed by
        re-running that single session through the engine, then overlaying the
        viewer's fee schedule on the output. Expect a response time of
        **seconds**, not milliseconds; the result is cached server-side for a
        few minutes.

        `reconciliation_warning: true` flags that the recomputed gross P&L no
        longer matches the stored result (the engine changed since the session
        was computed); a global results recompute will follow.
      parameters:
        - $ref: '#/components/parameters/StrategyId'
        - in: path
          name: date
          required: true
          schema:
            type: string
            format: date
      responses:
        '200':
          description: Recomputed session detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionDayDetail'
        '404':
          description: No result for this session
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimpleErrorEnvelope'
        '429':
          $ref: '#/components/responses/PlainTextError'
        '500':
          $ref: '#/components/responses/PlainTextError'

  /strategies/{id}/results/update:
    post:
      tags: [strategies]
      summary: Update results with newly recorded sessions
      description: |
        Authenticated. Runs the missing sessions and refolds the summaries —
        shared with everyone using the same configuration. Subject to the
        3-active-runs cap (`429 too_many_active_backtests`; retry with
        backoff). Failed sessions are retried by an explicit update.
      parameters:
        - $ref: '#/components/parameters/StrategyId'
      responses:
        '202':
          description: Update queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: queued
        '401':
          $ref: '#/components/responses/PlainTextError'
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimpleErrorEnvelope'
        '429':
          $ref: '#/components/responses/TooManyActiveRuns'

  /__ws:
    get:
      tags: [websocket]
      summary: Live market data WebSocket
      description: |
        WebSocket endpoint. After connecting, the client has 5 seconds to send an
        auth message:

        ```json
        {"auth": "public"}
        {"token": "<session-token>"}
        ```

        After auth, subscribe / unsubscribe to market-data channels. A freshly
        authenticated connection has no channel subscriptions — clients must
        explicitly subscribe to receive `live_aggregate_data` or
        `live_option_chain`. `live_option_chain` subscriptions require a
        token-authenticated user; public conns may subscribe only to
        `live_aggregate_data`.

        ```json
        {"action": "subscribe",   "channels": ["live_aggregate_data"]}
        {"action": "unsubscribe", "channel":  "live_option_chain"}
        ```

        **Server → client wire format.** Every message the server sends is a
        JSON object whose top-level `channel` field names the channel it
        belongs to. This is the routing key — clients dispatch on `channel`
        and never inspect payload shape. The accompanying data lives under
        `payload` for `live_aggregate_data`, `live_option_chain`, and
        `end_of_data`; `simulation_events` additionally carries `id` and
        `type` at the top level (see `SimulationEvent` schema).

        ```json
        {"channel": "live_aggregate_data", "payload": {"datetime": "...", ...}}
        {"channel": "live_option_chain",   "payload": [{"strike": 5950, "call": {"bid": 4.2, "ask": 4.4, "delta": 0.523}, "put": {"bid": 3.1, "ask": 3.3, "delta": 0.477}}, ...]}
        {"channel": "simulation_events",   "id": 42, "type": "order_update", "payload": {...}}
        {"channel": "end_of_data"}
        ```

        **Client → server convention.** Today the only client messages are
        the auth handshake (`{"auth":"public"}` / `{"token":"..."}`) and
        subscribe / unsubscribe envelopes. Any future client message bound to
        a specific channel MUST include a top-level `channel` field for the
        same reason: routing without shape sniffing. Auth and
        subscribe / unsubscribe are exempt because they are protocol-level
        messages, not channel data.

        Channels:
        - `live_aggregate_data` — opt-in via subscribe (1s cadence).
        - `live_option_chain` — opt-in via subscribe for token-authenticated
          users only. `payload` is the SPX option chain array whenever new
          chain data is published; each entry is `{strike, call, put}` and each
          side carries only `bid`, `ask`, and `delta`. For the full chain with
          all Greeks/IV/theo, use `GET
          /market-data/option-chain-snapshots/{timestamp}` (`SnapshotMap`).
        - `simulation_events` — opt-in via subscribe. Per-sim subscriptions:
          send `{"action":"subscribe","channel":"simulation_events","live_simulation_id":"<uuid>"}`
          to receive envelopes for that sim. A client tracking more than one
          sim may hold subscriptions to each independently. As a
          backwards-compatible fallback, a token-authenticated conn that has
          NO explicit per-sim subscription will still receive envelopes for
          its active manual sim. Each envelope carries `live_simulation_id`
          at the top level so clients route to per-sim state. Envelope
          shape: see `SimulationEvent` schema.
        - `backtest_events` — opt-in via subscribe, **per source hash**
          Strategy-results progress frames for one
          content-addressed backtest. Subscribe with the strategy's
          `source_hash`:
          `{"action":"subscribe","channel":"backtest_events","source_hash":"<hex>"}`
          — authorized against a saved strategy of yours with that source or
          your live preview interest. Each frame carries the full
          fee-overlaid results snapshot under `data.snapshot` (the same shape
          `GET /strategies/preview/{source_hash}/results` returns), folded for
          you, so render straight from it — no per-tick refetch. The HTTP
          results endpoint stays the source of truth for the initial load and
          reconnect (frames are swept minutes after a run ends; no replay).
          `data.type` is one of `started`, `day_completed`, `completed`,
          `stopped`, `failed`. A dropped socket never stops a run; reconnect
          and re-subscribe. Envelope:
          `{"channel":"backtest_events","id":N,"data":{"backtest_id":…,
          "source_hash":…,"type":…,"snapshot":{…ResultsSnapshot…}}}`.
        - `end_of_data` — terminal sentinel. Sent before the server closes a
          connection (the 5-minute public cap). No `payload` field.

        Subscriptions sent before auth completes are ignored. Unknown channels
        in `subscribe` are silently dropped.

        WebSocket upgrade is only accepted from origins listed in the
        `ALLOWED_WS_ORIGINS` environment allowlist (development default
        allows any).

        Connections authenticated as `{"auth": "public"}` (no account) stream
        for at most 5 minutes. When the cap is reached the server sends a final
        message and closes the connection:

        ```json
        {"channel": "end_of_data"}
        ```

        Token-authenticated connections are not subject to the
        5-minute cap and have no concurrency limit.

        **REST/WS consistency contract.** Mutating REST responses for
        `/live/orders` may arrive at the client before or after the
        corresponding `simulation_events` envelope. REST is authoritative for
        the immediate operation; the WS envelope is the source of truth for
        any state change the client did not initiate (other tabs, matcher
        fills, session close). Clients should de-dupe by `live_orders.id`
        and prefer the higher `updated_at` value when reconciling.

        **Reconnect contract.** The server does not buffer past events on
        disconnect. After reconnecting and re-authenticating, clients with an
        active live sim must refetch `GET /live/positions`,
        `/live/orders`, and `/live/transactions`. The server's missed-event
        recovery only covers transient NOTIFY drops on a connection that
        stayed open.

        **Per-sim replay for `simulation_events`.** Subscribe with
        `live_simulation_id` and an optional `last_event_by_sim` map (sim id →
        highest seen `live_simulation_events.id`) and the server replays any
        missing rows for that sim before live envelopes resume.
        (`backtest_events` deliberately has NO replay: each frame already
        carries the full current snapshot, so a missed frame self-heals on the
        next one, and the HTTP results endpoint backs the initial load and
        reconnect.)
      responses:
        '101':
          description: Switching protocols (WebSocket upgrade)

components:
  securitySchemes:
    bearerToken:
      type: apiKey
      in: header
      name: Authorization
      description: |
        Pass the session token returned by `POST /auth/sessions` as the bare
        value of the `Authorization` header (no `Bearer ` prefix).

  parameters:
    SimulationId:
      in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid

    OrderId:
      in: path
      name: orderId
      required: true
      schema:
        type: string
        format: uuid

    MarketDataDate:
      in: path
      name: date
      required: true
      schema:
        type: string
        format: date
        example: '2025-01-15'

    SnapshotTimestamp:
      in: path
      name: timestamp
      required: true
      schema:
        type: string
        example: '2025-01-15T09:30:00'
        description: ISO-like timestamp without timezone (`YYYY-MM-DDTHH:MM:SS`).

    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: false
      description: |
        Optional client-supplied key (RFC 9462). Duplicates within the
        retention window return the prior request's mapped response. Scoped
        per user and request type.
      schema:
        type: string
        maxLength: 200

    StrategyId:
      in: path
      name: id
      required: true
      schema:
        type: string
        format: uuid

  responses:
    PlainTextError:
      description: Plain-text error message
      content:
        text/plain:
          schema:
            type: string
            example: 'invalid order type, must be market, limit, or stop'

    TooManyActiveRuns:
      description: |
        The caller already has **3** backtest runs executing (the per-user
        concurrency cap that protects the shared backtester — there are no
        run credits or charges). JSON envelope. Clients should retry with
        backoff (3 s doubling to 30 s); a slot frees as soon as one of the
        caller's runs finishes. Saving a strategy never returns this — a save
        at the cap parks its run and starts it automatically later.
      content:
        application/json:
          schema:
            type: object
            required: [error]
            properties:
              error:
                type: string
                example: 'too_many_active_backtests'
              message:
                type: string
              max_active_runs:
                type: integer
                example: 3

    RateLimit429:
      description: |
        Rate-limit exceeded or concurrent-run cap hit. Carries
        `Retry-After: <seconds>` (RFC 9110) and
        `Content-Type: application/json`. Successful (2xx) responses
        through the same middleware carry `X-RateLimit-Used` and
        `X-RateLimit-Limit` headers so the client can keep its credits
        meter live.
      headers:
        Retry-After:
          description: |
            Seconds until the bucket has drained enough to retry.
            Present only when `error == "rate_limit_exceeded"`.
          schema:
            type: integer
            minimum: 0
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/RateLimitErrorEnvelope'
          examples:
            rate_limit_exceeded:
              value:
                error: 'rate_limit_exceeded'
                retry_after_seconds: 87
                credits_used: 4017
                credits_cap: 10000
            concurrent_run_cap_reached:
              value:
                error: 'concurrent_run_cap_reached'
                credits_used: 0
                credits_cap: 5

  schemas:
    # --- auth & user ---

    CheckEmailRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email

    CheckEmailResponse:
      type: object
      required: [hasPassword]
      properties:
        hasPassword:
          type: boolean
          description: True if the account has a password set (vs. passwordless code-only login).

    VerifyEmailRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email

    RegisterRequest:
      type: object
      required: [email, verification_code]
      properties:
        email:
          type: string
          format: email
        password:
          type: string
          description: Optional. If present, must meet the password policy.
        verification_code:
          type: string
          example: '123456'

    ForgotPasswordRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email

    ResetPasswordRequest:
      type: object
      required: [token, new_password]
      properties:
        token:
          type: string
          format: uuid
        new_password:
          type: string

    PostSessionRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email
        password:
          type: string
        verification_code:
          type: string
          description: Use instead of password for one-time login via verification code.
      description: Provide either `password` or `verification_code` (not both).

    PostSessionResponse:
      type: object
      required: [token]
      properties:
        token:
          type: string

    User:
      type: object
      required: [id, email, usage_percent, fee_schedule, slippage]
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
        usage_percent:
          type: number
          format: float
          description: Rate-limit bucket usage (0-100).
        fee_schedule:
          allOf:
            - $ref: '#/components/schemas/FeeSchedule'
          description: |
            The simulator fee schedule that will actually apply to this user's
            orders. The platform default unless the user has stored a custom
            override, in which case this reflects their custom values.
        slippage:
          type: string
          description: |
            The account-level slippage applied to all fills (historical, live,
            and backtest) — a multiple of \$0.05 from \$0.00 to \$1.00 that
            worsens limit/stop fills in the market maker's favor. New accounts
            default to `"0.05"`; `"0"` when unset.

    FeeSchedule:
      type: object
      description: |
        All values are decimal strings. Equity fees are per share; option fees
        are per contract; the exercise fee is per strike.
      required:
        - buy_equity
        - sell_equity
        - buy_to_open_option
        - sell_to_open_option
        - buy_to_close_option
        - sell_to_close_option
        - exercise_option
      properties:
        # BEGIN generated fee-schedule examples — do not edit; run `npm run gen:fees`
        buy_equity: { type: string, example: '0.0008' }
        sell_equity: { type: string, example: '0.003986' }
        buy_to_open_option: { type: string, example: '1.72' }
        sell_to_open_option: { type: string, example: '1.72' }
        buy_to_close_option: { type: string, example: '0.72' }
        sell_to_close_option: { type: string, example: '0.72' }
        exercise_option: { type: string, example: '5' }
        # END generated fee-schedule examples

    PatchUserRequest:
      type: object
      description: |
        At least one field must be provided. Provide `email + verification_code`
        to change email, or `password + verification_code` to change password
        (not both at once). Send `"fee_schedule": null` to clear a custom
        schedule and revert to defaults. When sending an object, all 7 fields
        are required, must be non-negative, and capped at 10× the platform
        default. Send `slippage` as a decimal string (a multiple of \$0.05 from
        \$0.00 to \$1.00) to set the account-level slippage, or `null` to clear
        it. New accounts start at \$0.05.
      properties:
        email:
          type: string
          format: email
        password:
          type: string
        verification_code:
          type: string
        fee_schedule:
          oneOf:
            - $ref: '#/components/schemas/FeeSchedule'
            - type: 'null'
        slippage:
          oneOf:
            - type: string
            - type: 'null'
          description: |
            Account-level slippage as a decimal string (e.g. `"0.05"`), or `null`
            to clear it. Must be a multiple of \$0.05 from \$0.00 to \$1.00.

    # --- simulation ---

    PostSimulationRequest:
      type: object
      required: [date, starting_capital]
      properties:
        date:
          type: string
          format: date
          example: '2025-01-15'
        starting_capital:
          oneOf:
            - type: number
            - type: string
          description: Decimal in USD. Must be in `[1000, 10000000]`.
        description:
          type: string

    PatchSimulationRequest:
      type: object
      required: [time]
      properties:
        time:
          type: string
          format: date-time
          description: ISO 8601 timestamp within `[start_time, end_time]`.

    Simulation:
      type: object
      required:
        - id
        - date
        - starting_capital
        - ended
        - deposits
        - withdrawals
        - maintenance_buying_power
        - net_liquidation_value
        - equities_maintenance_buying_power
        - available_buying_power
        - equities_net_liquidation_value
        - equity_options_maintenance_buying_power
        - equity_options_net_liquidation_value
        - credits
        - debits
        - fees
        - unrealized_profit_loss
        - realized_profit_loss
        - profit_loss
        - equity_options_credits
        - equity_options_debits
        - equity_options_fees
        - equity_options_unrealized_profit_loss
        - equity_options_realized_profit_loss
        - equity_options_profit_loss
        - equities_credits
        - equities_debits
        - equities_fees
        - equities_unrealized_profit_loss
        - equities_realized_profit_loss
        - equities_profit_loss
      properties:
        id:
          type: string
          format: uuid
        date:
          type: string
          format: date
        starting_capital:
          type: string
          description: Decimal value as string (preserves precision).
        description:
          type: string
        ended:
          type: boolean
          description: True once `time` reaches or passes `end_time` (16:00 ET).
        time:
          type: string
          format: date-time
          description: Current simulation time. Null until first `PATCH`.
        start_time:
          type: string
          format: date-time
          description: Session open (typically 09:30 ET).
        end_time:
          type: string
          format: date-time
          description: Session close (16:00 ET).
        deposits:
          type: string
        withdrawals:
          type: string
        maintenance_buying_power:
          type: string
        net_liquidation_value:
          type: string
        equities_maintenance_buying_power:
          type: string
        available_buying_power:
          type: string
        equities_net_liquidation_value:
          type: string
        equity_options_maintenance_buying_power:
          type: string
        equity_options_net_liquidation_value:
          type: string
        credits:
          type: string
        debits:
          type: string
        fees:
          type: string
        unrealized_profit_loss:
          type: string
        realized_profit_loss:
          type: string
        profit_loss:
          type: string
        equity_options_credits:
          type: string
        equity_options_debits:
          type: string
        equity_options_fees:
          type: string
        equity_options_unrealized_profit_loss:
          type: string
        equity_options_realized_profit_loss:
          type: string
        equity_options_profit_loss:
          type: string
        equities_credits:
          type: string
        equities_debits:
          type: string
        equities_fees:
          type: string
        equities_unrealized_profit_loss:
          type: string
        equities_realized_profit_loss:
          type: string
        equities_profit_loss:
          type: string

    SimulationHistoryEntry:
      type: object
      required:
        - timestamp
        - net_liquidation_value
        - profit_loss
        - unrealized_profit_loss
        - realized_profit_loss
        - credits
        - debits
        - fees
        - maintenance_buying_power
        - available_buying_power
        - equity_options_credits
        - equity_options_debits
        - equity_options_fees
        - equity_options_unrealized_profit_loss
        - equity_options_realized_profit_loss
        - equity_options_profit_loss
        - equity_options_net_liquidation_value
        - equities_credits
        - equities_debits
        - equities_fees
        - equities_unrealized_profit_loss
        - equities_realized_profit_loss
        - equities_profit_loss
        - equities_net_liquidation_value
      properties:
        timestamp:
          type: string
          format: date-time
        net_liquidation_value: { type: string }
        profit_loss: { type: string }
        unrealized_profit_loss: { type: string }
        realized_profit_loss: { type: string }
        credits: { type: string }
        debits: { type: string }
        fees: { type: string }
        maintenance_buying_power: { type: string }
        available_buying_power: { type: string }
        equity_options_credits: { type: string }
        equity_options_debits: { type: string }
        equity_options_fees: { type: string }
        equity_options_unrealized_profit_loss: { type: string }
        equity_options_realized_profit_loss: { type: string }
        equity_options_profit_loss: { type: string }
        equity_options_net_liquidation_value: { type: string }
        equities_credits: { type: string }
        equities_debits: { type: string }
        equities_fees: { type: string }
        equities_unrealized_profit_loss: { type: string }
        equities_realized_profit_loss: { type: string }
        equities_profit_loss: { type: string }
        equities_net_liquidation_value: { type: string }

    # --- orders ---

    OrderLeg:
      type: object
      required: [instrument, quantity, action]
      properties:
        instrument:
          type: string
          description: |
            Canonical OPRA/OSI instrument string. Examples:
            - Equity: `<SYMBOL>` (bare ticker), e.g. `SPX`.
            - Equity option: the 21-char OSI form `<ROOT><YYMMDD><C|P><strike×1000>` —
              a 6-char root left-justified and space-padded, the `YYMMDD` expiry
              date, a `C` or `P` side letter, and the strike × 1000 zero-padded
              to 8 digits. SPX 0 DTE options use the `SPXW` weekly root (so
              `SPXW` followed by two spaces). For example a 5950 call expiring
              2025-01-15 is `SPXW  250115C05950000`.

            The option is identified by root + date + side + strike; the
            session-close time is implied, not encoded.
          example: 'SPXW  250115C05000000'
        quantity:
          oneOf:
            - type: string
            - type: number
          example: '1'
        action:
          type: string
          enum:
            - 'buy to open'
            - 'sell to open'
            - 'buy to close'
            - 'sell to close'

    PostOrderRequest:
      type: object
      required: [type, legs]
      properties:
        type:
          type: string
          enum: [market, limit, stop]
          description: |
            `market` fills at the natural price (ask for a buy, bid for a sell).
            `limit`/`stop` fill at the mid-based execution price. ONLY `limit`
            orders may be multi-leg — a `market` or `stop` order with more than
            one leg is rejected with HTTP 400.
        price:
          type: string
          description: |
            Required for `limit`. Optional for `stop`. SPX option tick rules apply
            (single-leg < \$3 → \$0.05; ≥ \$3 → \$0.10; multi-leg → \$0.05).
            On `limit` orders, `price` cannot exceed the order's structural
            maximum profit (e.g. a $5-wide vertical caps at $5.00). This check
            is skipped when the order's max profit is unbounded.
        price_effect:
          type: string
          enum: [debit, credit]
          description: Required for `limit` and `stop`. Auto-inferred for `market`.
        stop_trigger:
          type: string
          description: Required for `stop`. Same SPX tick rules as `price`.
        legs:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/OrderLeg'
        client_order_id:
          type: string
          format: uuid
          description: |
            **Honored only on `/live/orders`.**
            Optional client-supplied UUID used by the matcher as the
            inserted `live_orders.id`. A replay-after-crash with the same
            `client_order_id` resolves to the existing terminal row
            instead of re-running buying-power / fee / financials. When
            omitted, the API derives one deterministically from the
            `Idempotency-Key` header (if present) or generates a fresh
            UUID per request. Ignored by `/orders` (historical sim).

    Order:
      type: object
      required: [id, datetime, underlying, type, legs, status, fees]
      properties:
        id:
          type: string
          format: uuid
        datetime:
          type: string
          format: date-time
          description: Order creation time (in simulation time).
        underlying:
          type: string
          example: SPX
        type:
          type: string
          enum: [market, limit, stop]
        price:
          type: string
          description: Limit price. Omitted when zero / not applicable.
        price_effect:
          type: string
          enum: [debit, credit]
          description: Omitted when empty.
        stop_trigger:
          type: string
          description: Omitted when zero / not applicable.
        slippage:
          type: string
          description: |
            The slippage applied to this order, resolved from the account-level
            `slippage` setting (a non-negative multiple of \$0.05 that worsens the
            fill in the market maker's favor). Omitted when zero.
        execution_price:
          type: string
          description: |
            The mid the order must reach to fill — `price ∓ slippage` (debit −,
            credit +) for a limit, the trigger for a stop. Present (when set)
            once the order is filled. Omitted when zero / not applicable.
        fees:
          type: string
          description: |
            For filled orders, the sum of fees from this order's transactions
            whose `datetime <= sim.time`. For pending limit/stop orders, an
            estimate from the fee schedule based on legs and quantities,
            shown so callers see the projected cost before fill. Realized
            fees only impact simulation cash totals after the order fills.
        status:
          type: string
          enum: [pending, filled]
        fill_price:
          type: string
          description: Present only when `status == filled`.
        fill_datetime:
          type: string
          format: date-time
          description: Present only when `status == filled`.
        fill_price_effect:
          type: string
          enum: [credit, debit]
          description: |
            Net cash-flow direction at fill, derived from the order's
            transactions. Present only when `status == filled` and the net
            is non-zero. For simple structures this matches the declared
            `price_effect`; for orders whose direction is quote-dependent
            (e.g. strangles) it reflects the actual fill direction.
        legs:
          type: array
          items:
            $ref: '#/components/schemas/OrderLeg'
        transactions:
          type: array
          description: Present only when `status == filled`.
          items:
            $ref: '#/components/schemas/OrderTransaction'
        buying-power-effect:
          $ref: '#/components/schemas/BuyingPowerEffect'

    BuyingPowerEffect:
      description: |
        Projected impact of an order on maintenance margin and available
        buying power, computed at order placement time. Margin fields cover
        only the maintenance-margin component; buying-power fields cover
        margin + order net debit/credit + estimated fees.

        - `change-*` reflect the delta between current and new state.
        - `current-buying-power` matches the simulation's
          `available_buying_power` (filled state plus pending orders).
        - `new-buying-power` is what `available_buying_power` will be after
          this order projects onto the account.
        - `value` / `effect` mirror `change-in-buying-power` /
          `change-in-buying-power-effect`.

        Effects: `Debit` for outflow / margin used / BP shrunk; `Credit`
        for inflow / margin freed / BP grew; `None` when zero.
      type: object
      required:
        - change-in-margin-requirement
        - change-in-margin-requirement-effect
        - change-in-buying-power
        - change-in-buying-power-effect
        - current-buying-power
        - current-buying-power-effect
        - new-buying-power
        - new-buying-power-effect
        - value
        - effect
      properties:
        change-in-margin-requirement: { type: string }
        change-in-margin-requirement-effect:
          type: string
          enum: [Debit, Credit, None]
        change-in-buying-power: { type: string }
        change-in-buying-power-effect:
          type: string
          enum: [Debit, Credit, None]
        current-buying-power: { type: string }
        current-buying-power-effect:
          type: string
          enum: [Debit, Credit, None]
        new-buying-power: { type: string }
        new-buying-power-effect:
          type: string
          enum: [Debit, Credit, None]
        value: { type: string }
        effect:
          type: string
          enum: [Debit, Credit, None]

    OrderTransaction:
      description: Embedded transaction view returned inline on filled orders.
      type: object
      required: [id, datetime, instrument, type, quantity, price, value, effect, fees]
      properties:
        id:
          type: string
          format: uuid
        datetime:
          type: string
          format: date-time
        instrument:
          type: string
        type:
          $ref: '#/components/schemas/TransactionType'
        quantity:
          type: string
        price:
          type: string
        value:
          type: string
        effect:
          type: string
          enum: [debit, credit]
        fees:
          type: string

    # --- positions ---

    Position:
      type: object
      required:
        - id
        - instrument
        - direction
        - quantity
        - cost
        - cost_basis
        - debit
        - credit
        - unrealized_profit_loss
        - realized_profit_loss
        - fees
        - total_profit_loss
        - net_liquidity
        - price
        - delta
      properties:
        id:
          type: string
          format: uuid
          description: Synthetic ID derived from simulation + instrument + direction.
        instrument:
          type: string
        direction:
          type: string
          enum: [long, short]
        quantity:
          type: string
        cost:
          type: string
        cost_basis:
          type: string
        debit:
          type: string
        credit:
          type: string
        unrealized_profit_loss:
          type: string
        realized_profit_loss:
          type: string
        fees:
          type: string
        total_profit_loss:
          type: string
        net_liquidity:
          type: string
        price:
          type: string
        delta:
          type: string

    # --- transactions ---

    TransactionType:
      type: string
      enum:
        - 'buy to open'
        - 'sell to open'
        - 'buy to close'
        - 'sell to close'
        - expiration
        - exercise
        - assignment
        - deposit
        - withdrawal

    Transaction:
      type: object
      required: [id, datetime, instrument, type, quantity, fees]
      properties:
        id:
          type: string
          format: uuid
        datetime:
          type: string
          format: date-time
        instrument:
          type: string
          description: Empty string for non-instrument transactions (e.g. `deposit`).
        type:
          $ref: '#/components/schemas/TransactionType'
        quantity:
          type: string
        fees:
          type: string
        price:
          type: string
          description: Omitted for `expiration`.
        value:
          type: string
          description: Omitted for `expiration`.
        effect:
          type: string
          enum: [debit, credit]
          description: Omitted for `expiration`.
        order_id:
          type: string
          format: uuid
          nullable: true
          description: Null for settlement / deposit / withdrawal transactions.

    # --- live trading ---

    PostLiveRequest:
      type: object
      required: [starting_capital]
      properties:
        starting_capital:
          type: string
          description: Decimal value as string. Range 1000–10000000.
        description:
          type: string

    LiveSimulation:
      allOf:
        - $ref: '#/components/schemas/Simulation'
        - type: object
          properties:
            settled_at:
              type: string
              format: date-time
              nullable: true
              description: |
                Set after end-of-day settlement completes. Until then the
                simulation is "closed but not settled" — `ended` may still be
                `false` and the user's positions remain in their last-tick
                state until the recorder publishes the close-time tick.

    PutLiveOrderRequest:
      type: object
      required: [price, price_effect]
      description: |
        Replacement payload for `PUT /live/orders/{id}`. Only the price and
        price effect of the target order may change — type, legs and underlying
        are immutable across a replacement. Slippage is re-resolved from the
        account-level setting, not supplied here.
      properties:
        price:
          type: string
          description: |
            New limit price as a decimal string. Must be strictly greater
            than 0.
        price_effect:
          type: string
          enum: [debit, credit]
          description: |
            Effect of the new price. Must match the structural debit/credit
            sign of the (unchanged) legs for single-leg orders, verticals,
            and iron condors; ignored for ambiguous structures.

    LiveOrder:
      type: object
      required: [id, datetime, accepted_seq, underlying, type, legs, status, fees]
      description: |
        Live orders never time-travel. `status` is the persisted lifecycle
        state and stays terminal once set. Status-conditional blocks:
        - `filled`: `fill_datetime`, `fill_price`, `fill_price_effect`, `transactions`
        - `canceled`: `canceled_datetime`
        - `expired`: `expired_datetime`
        - `rejected`: `rejected_datetime`, `rejection_reason`
      properties:
        id:
          type: string
          format: uuid
        datetime:
          type: string
          format: date-time
          description: Acceptance time (the exchange's processing time).
        accepted_seq:
          type: integer
          format: int64
          description: |
            Per-sim monotonic sequence assigned at acceptance. Replay sorts
            pending orders by this for deterministic fill ordering.
        underlying:
          type: string
        type:
          type: string
          enum: [market, limit, stop]
          description: |
            ONLY `limit` orders may be multi-leg; `market` and `stop` orders
            must be single-leg.
        price:
          type: string
        price_effect:
          type: string
          enum: [debit, credit]
        stop_trigger:
          type: string
        slippage:
          type: string
          description: |
            The slippage applied to this order, resolved from the account-level
            `slippage` setting (a non-negative multiple of \$0.05 that worsens the
            fill in the market maker's favor). Omitted when zero.
        execution_price:
          type: string
          description: |
            The mid the order must reach to fill — `price ∓ slippage`. Omitted
            when zero / not applicable.
        legs:
          type: array
          items:
            $ref: '#/components/schemas/OrderLeg'
        fees:
          type: string
        status:
          type: string
          enum: [pending, filled, canceled, expired, rejected]
        fill_datetime:
          type: string
          format: date-time
          description: Present when `status=filled`.
        fill_price:
          type: string
          description: Present when `status=filled`.
        fill_price_effect:
          type: string
          enum: [credit, debit]
          description: |
            Net cash-flow direction at fill, derived from the order's
            transactions. Present when `status=filled` and the net is
            non-zero.
        canceled_datetime:
          type: string
          format: date-time
          description: Present when `status=canceled`.
        expired_datetime:
          type: string
          format: date-time
          description: Present when `status=expired`.
        rejected_datetime:
          type: string
          format: date-time
          description: Present when `status=rejected`.
        rejection_reason:
          type: string
          description: |
            Present when `status=rejected`. Defined values include
            `insufficient_buying_power`.
        transactions:
          type: array
          description: Present when `status=filled`. One entry per leg.
          items:
            $ref: '#/components/schemas/Transaction'
        buying-power-effect:
          $ref: '#/components/schemas/BuyingPowerEffect'

    SimulationEvent:
      type: object
      required: [channel, id, live_simulation_id, type, payload]
      description: |
        WebSocket envelope for a live-trading event. Forwarded over the
        `__ws` connection to every conn that has either an explicit per-sim
        subscription matching `live_simulation_id` or (for the user's manual
        sim) the legacy auto-subscription.
      properties:
        channel:
          type: string
          enum: [simulation_events]
        id:
          type: integer
          format: int64
          description: |
            Outbox row id (monotonically increasing per simulation). Clients
            should track the highest id received per `live_simulation_id`.
        live_simulation_id:
          type: string
          format: uuid
          description: |
            The sim this envelope belongs to. Clients route to per-sim state.
        type:
          type: string
          enum: [order_update, financials_update, ended, settlement_failed]
        payload:
          type: object
          description: |
            For `order_update`: the full LiveOrder JSON. For
            `financials_update`: cash balance, buying power, realized P&L,
            position quantities by leg (per-tick continuous values like NLV
            and unrealized P&L are not included; clients compute those from
            the live option-chain stream). For `ended` and
            `settlement_failed`: empty object.

    # --- market data ---

    MarketSessionsResponse:
      type: object
      additionalProperties:
        $ref: '#/components/schemas/MarketSessionEntry'
      description: |
        Object whose keys are session dates (`YYYY-MM-DD`).
      example:
        '2025-01-15':
          start-time: '2025-01-15T14:30:00Z'
          end-time: '2025-01-15T21:00:00Z'
          data-start-time: '2025-01-15T14:31:00Z'
          data-end-time: '2025-01-15T21:00:00Z'
          current: false
          upcoming: false

    MarketSessionEntry:
      type: object
      required: [start-time, end-time, data-start-time, data-end-time]
      properties:
        start-time:
          type: string
          format: date-time
          description: Actual trading session open as reported by the broker (typically 09:30 ET).
        end-time:
          type: string
          format: date-time
          description: Actual trading session close as reported by the broker (typically 16:00 ET).
        data-start-time:
          type: string
          format: date-time
          description: Earliest timestamp for which historical market data is available for this session. Typically a minute or two after `start-time`.
        data-end-time:
          type: string
          format: date-time
          description: Latest timestamp for which historical market data is available for this session.
        restricted:
          type: boolean
          description: True if the caller cannot access this session (public callers see only the most recent completed session).
        current:
          type: boolean
          description: True if `now` is between `start-time` and `end-time`.
        upcoming:
          type: boolean
          description: True if `start-time` is in the future.

    HistoricalDataPoint:
      type: object
      required: [datetime, datetimeUnix]
      properties:
        datetime:
          type: string
          format: date-time
        datetimeUnix:
          type: integer
          format: int64
        spx:
          type: string
        vix:
          type: string
        spx_expected_move:
          type: string
        spx_otm_bids:
          type: string
        spx_extrinsic:
          type: string
      description: |
        Only the series requested via `?series=` are present (besides `datetime`
        and `datetimeUnix`).

    SnapshotQuote:
      type: object
      required: [bid, ask, delta]
      properties:
        bid: { type: number, format: float }
        ask: { type: number, format: float }
        delta:
          type: number
          format: float
          description: |
            Decimal 0..1, unsigned (calls and puts both reported as
            `Abs(delta)`). Multiply by 100 for "30-delta" shorthand.

    SnapshotMap:
      type: object
      additionalProperties:
        $ref: '#/components/schemas/SnapshotQuote'
      description: |
        Keyed by `call_<strike>` and `put_<strike>` (e.g. `call_5000`, `put_5000`).

    SnapshotRangeEntry:
      type: object
      required: [timestamp, options]
      properties:
        timestamp:
          type: string
          example: '2025-01-15T09:30:00'
        options:
          $ref: '#/components/schemas/SnapshotMap'

    # --- error envelopes ---

    SimpleErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: string
          example: 'not_found'

    RateLimitErrorEnvelope:
      type: object
      required: [error, credits_used, credits_cap]
      description: |
        JSON envelope returned by `RateLimitVariable` on `429`. The
        `retry_after_seconds` field is present only when
        `error == "rate_limit_exceeded"`; the concurrent-run-cap
        rejection has no meaningful drain to advertise.
      properties:
        error:
          type: string
          enum: [rate_limit_exceeded, concurrent_run_cap_reached]
        retry_after_seconds:
          type: integer
          description: |
            Mirrors the `Retry-After` header (seconds). Present only for
            `rate_limit_exceeded`.
        credits_used:
          type: integer
          description: Bucket usage at the moment of rejection.
        credits_cap:
          type: integer
          description: |
            For `rate_limit_exceeded`, the bucket capacity (constant,
            currently 10000). For `concurrent_run_cap_reached`, the caller's
            per-user concurrent-run cap instead.

    # --- strategies (AI authoring) ---

    SavedStrategyListItem:
      type: object
      description: |
        One saved strategy on the leaderboard list. `metrics` is the
        headline fold (btmetrics summary), net of the default
        fee schedule; decimals are JSON strings.
      required: [id, source_hash, exec_status, privacy]
      properties:
        id:
          type: string
          format: uuid
          description: Your saved-strategy id (per-user; logic is shared).
        source_hash:
          type: string
        privacy:
          type: string
          enum: [private, public]
          description: Your link's visibility. New strategies default to private.
        description:
          type: string
          description: Your override, else the generated description.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        shared_since:
          type: string
          format: date-time
          description: When the shared strategy was first published by anyone.
        config:
          $ref: '#/components/schemas/BuilderConfig'
        exec_status:
          type: string
          enum: [idle, running, failed]
        covered_days:
          type: integer
        total_sessions:
          type: integer
        new_sessions_available:
          type: integer
        metrics:
          $ref: '#/components/schemas/SummaryMetrics'

    StrategyDetail:
      type: object
      description: One saved strategy with its results snapshot.
      required: [id, source_hash, origin, description, privacy, is_owner]
      properties:
        id:
          type: string
          format: uuid
        source_hash:
          type: string
        privacy:
          type: string
          enum: [private, public]
          description: The link's visibility.
        is_owner:
          type: boolean
          description: |
            Whether the authenticated caller owns this link. `false` for a
            public strategy viewed by another user or an unauthenticated
            visitor — clients use it to gate owner-only controls (privacy,
            delete, update).
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        origin:
          type: string
          enum: [builder, assistant]
        config:
          $ref: '#/components/schemas/BuilderConfig'
        summary:
          type: object
          additionalProperties: true
          nullable: true
        risks:
          type: object
          additionalProperties: true
          nullable: true
        description:
          type: string
        snapshot:
          $ref: '#/components/schemas/ResultsSnapshot'

    PreviewResponse:
      type: object
      description: POST /strategies/preview body — the snapshot plus the generated artifacts.
      required: [snapshot, description]
      properties:
        snapshot:
          $ref: '#/components/schemas/ResultsSnapshot'
        summary:
          type: object
          additionalProperties: true
          nullable: true
        risks:
          type: object
          additionalProperties: true
          nullable: true
        description:
          type: string
          description: Generated markdown description of the configuration.

    ResultsSnapshot:
      type: object
      description: |
        The fee-overlaid results read model covering every recorded session
        (`window_label`/`window_from`/`window_to` state the range explicitly).
        Coverage is derived from the persisted per-session rows; `exec_status`
        is execution state only (a strategy with missing sessions and no run in
        flight is simply stale, not failed). All money figures are net of the
        caller's fee schedule (or the default).
      required: [backtest_id, source_hash, exec_status, total_sessions, covered_days, results]
      properties:
        backtest_id:
          type: string
          format: uuid
        source_hash:
          type: string
        exec_status:
          type: string
          enum: [idle, running, failed]
        date_from:
          type: string
          format: date
        date_to:
          type: string
          format: date
          description: Highest contiguously-covered session (display only).
        total_sessions:
          type: integer
        covered_days:
          type: integer
        new_sessions_available:
          type: integer
        update_allowed:
          type: boolean
        window_from:
          type: string
          format: date
        window_to:
          type: string
          format: date
        window_label:
          type: string
          example: first 100 recorded sessions
        session_dates:
          type: array
          description: >-
            Every recorded session in the window, in order. The chart uses it to
            pin the time axis to the full span so the equity line fills in
            left→right as days complete.
          items:
            type: string
            format: date
        results:
          type: object
          properties:
            equity_curve:
              type: array
              nullable: true
              items:
                type: object
                properties:
                  date:
                    type: string
                    format: date
                  nlv:
                    type: string
                    description: Decimal string.
            benchmark:
              type: array
              nullable: true
              items:
                type: object
                properties:
                  date:
                    type: string
                    format: date
                  value:
                    type: string
            metrics:
              $ref: '#/components/schemas/SummaryMetrics'
            days:
              type: array
              nullable: true
              items:
                $ref: '#/components/schemas/DaySlice'

    SummaryMetrics:
      type: object
      description: |
        Folded per-run metrics (decimals as JSON strings): n_trading_days,
        starting_capital, ending_nlv, total_return, cagr, sharpe_annualized,
        sortino_annualized, max_drawdown, calmar, win_rate, profit_factor,
        expectancy_per_day, best_day, worst_day, fees, slippage, streaks.
      additionalProperties: true

    DaySlice:
      type: object
      description: One session's viewer-facing row (gross from storage, net under your fee schedule).
      required: [date, status, gross_pnl, fees, net_pnl]
      properties:
        date:
          type: string
          format: date
        status:
          type: string
          enum: [completed, skipped, failed, halted]
        gross_pnl:
          type: string
        fees:
          type: string
        net_pnl:
          type: string
        num_orders:
          type: integer
        num_filled:
          type: integer
        error_message:
          type: string

    SessionDayDetail:
      type: object
      description: |
        The recomputed session drill-in. `overlay_fee` on each transaction is
        your fee schedule applied at read time (the stored run is fee-free);
        `slippage` is your per-contract slippage applied to the day's option
        fills; `net_pnl` is gross minus both. The intraday curve is likewise
        cost-adjusted.
      required: [date, status, gross_pnl, fees, slippage, net_pnl]
      properties:
        date:
          type: string
          format: date
        status:
          type: string
        gross_pnl:
          type: string
        fees:
          type: string
        slippage:
          type: string
        net_pnl:
          type: string
        num_orders:
          type: integer
        num_filled:
          type: integer
        orders:
          type: array
          nullable: true
          items:
            type: object
            additionalProperties: true
        transactions:
          type: array
          nullable: true
          items:
            type: object
            additionalProperties: true
        decision_log:
          type: array
          nullable: true
          items:
            type: object
            additionalProperties: true
        intraday_curve:
          type: array
          nullable: true
          description: Cost-adjusted (fees + slippage) intraday equity curve.
          items:
            type: object
            properties:
              t:
                type: string
                format: date-time
              nlv:
                type: string
        strategy_errors:
          type: array
          nullable: true
          items:
            type: string
        reconciliation_warning:
          type: boolean

    BuilderConfigError:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [invalid_config]
        fields:
          type: object
          additionalProperties:
            type: string
          description: |
            Per-field validation messages keyed by dotted config path
            (e.g. `legs.1.strike.value`).

    BuilderConfig:
      type: object
      description: |
        Structured 0 DTE SPX strategy config the Builder generates Starlark
        from. Persisted on the strategy as `builder_config` for round-trip
        editing.
      required: [legs, entry]
      properties:
        schema:
          type: integer
          description: Config schema version (currently 1).
        description:
          type: string
          description: Optional free-text label (≤ 120 chars); prefixes the generated strategy description.
        underlying:
          type: string
          enum: [SPX]
          description: Fixed to SPX in v1.
        legs:
          type: array
          minItems: 1
          maxItems: 4
          items:
            $ref: '#/components/schemas/BuilderLeg'
        entry:
          $ref: '#/components/schemas/BuilderEntry'
        exit:
          $ref: '#/components/schemas/BuilderExit'
        order:
          type: object
          properties:
            type:
              type: string
              enum: [market]

    BuilderLeg:
      type: object
      required: [direction, type, qty, strike]
      properties:
        direction:
          type: string
          enum: [buy, sell]
        type:
          type: string
          enum: [put, call]
        qty:
          type: integer
          minimum: 1
        strike:
          $ref: '#/components/schemas/BuilderStrike'

    BuilderStrike:
      type: object
      required: [method]
      properties:
        method:
          type: string
          enum: [delta, otm_pct, offset, premium, atm, expected_move, linked_price, linked_price_exact, linked_pct_otm]
          description: |
            The `linked_*` methods price this leg relative to another leg
            (see `parent`) instead of spot.
        value:
          type: number
          description: |
            `delta`: absolute delta 0–1. `otm_pct`: fraction of spot off the
            money (e.g. 0.02 = 2% OTM, negative = ITM; ±0.05). `offset`: points
            OTM (negative = ITM). `premium`: target option price in points,
            between 0.05 and 200 inclusive — the strike whose quote is nearest the
            target is chosen, matching the ask when the leg is bought and the bid
            when it is sold; ties resolve toward ATM when buying and away from ATM
            (deeper OTM) when selling. `atm`: ignored. `expected_move`: multiple
            of the daily 1-sigma expected move. `linked_price` /
            `linked_price_exact`: a ± strike offset in points from the parent
            leg's strike — approximate picks the nearest listed strike (ties →
            greater strike), exact requires that strike to be listed or the entry
            is skipped. `linked_pct_otm`: a fraction of the underlying applied
            to the parent's strike (e.g. -0.01 = 1% lower).
        parent:
          type: integer
          minimum: 0
          description: |
            0-based index of the leg this one is priced relative to. Required
            for the `linked_*` methods and must reference an earlier leg.

    BuilderEntry:
      type: object
      required: [times]
      properties:
        times:
          type: array
          minItems: 1
          items:
            type: string
            description: HH:MM ET, between 09:31 and 15:55.
        days_of_week:
          type: array
          nullable: true
          items:
            type: string
            enum: [mon, tue, wed, thu, fri, sat, sun]
          description: Omit / null = every trading day.

    BuilderExit:
      type: object
      description: |
        All optional; with none set the position rides to the 16:00 ET cash
        settlement.
      properties:
        profit_target_pct:
          type: number
          description: Take profit at X% of the entry premium.
        stop_loss_pct:
          type: number
          description: |
            For a net-credit structure, close when the cost to close reaches
            X% of the credit received; for a net-debit structure, close when
            the position value falls to (100 − X)% of the debit paid.
        time:
          type: string
          description: Hard time exit, HH:MM ET.
        minutes_before_close:
          type: integer
          description: Close this many minutes before the session close.
        minutes_in_trade:
          type: integer
          description: Close this many minutes after the entry fills (1–390).

    # --- websocket envelopes (new channels) ---
