openapi: 3.1.0
info:
  title: TRON Charts API
  version: 1.1.0
  summary: Multi-venue trading platform + DEX aggregator API
  description: |
    Tron Charts exposes a venue-agnostic trading + market-data + reporting
    API across crypto (Hyperliquid, Aster, Polymarket) and HIP-3 deployer
    perps (trade.xyz, Felix, Ventuals, etc.). Roadmap adds TradFi venues
    (B3, CME, IBKR) under the same surface.

    ## Execution model — routed vs. internalised

    An order reaches the market one of two ways, and the response
    fields tell you which:

    - **Routed (A-book)** — the order is signed and dispatched to the
      named exchange. `venue` is that exchange, and `venueOrderId` /
      `venueTradeId` are the exchange's own identifiers.
    - **Internalised (B-book)** — the firm is the counterparty. The
      order is matched by our own engine against a firm-canonical mark
      and **no order is placed at any exchange for it**. The firm
      hedges its aggregate exposure separately, on its own account, by
      an external algorithm; that hedge is not visible on the trading
      account and is not reported here.

    Rows written by the internalising engine carry `venue: "paper"` —
    the sentinel means "matched by our engine", not "simulated money".
    An internalised account settles real PnL; it is **not** a demo
    account, even though it shares that sentinel with the sandbox.
    Which mode applies is a property of the **account** (its configured
    execution mode), never of the venue string, so discriminate on the
    account rather than on `venue` if you need to tell them apart.

    On an internalised row `venueOrderId` / `venueTradeId` are minted
    by us. They are stable and safe to use as dedup keys, but they
    identify an internal match and resolve nowhere off-platform — do
    not build exchange-side reconciliation on them. Both are nullable
    and are null until an order has been dispatched.

    ## Authentication

    Two paths:

    1. **Cookie session** — browser FE. SIWE or social signin yields a
       `tron_session` cookie. Same-origin requests authenticate
       automatically.

    2. **Bearer JWT** — Provider API for integrators + algos. Mint
       via `POST /api/auth/api-token` with `apiKey` + `apiSecret`.
       JWT TTL: 24h. Carry as `Authorization: Bearer <jwt>` on every
       request.

    ## Tenant scope

    Every request resolves to a tenant via (in order):
    1. `X-Tenant-Slug` header (preferred for Provider API)
    2. Origin / Host header matched against `tenants.allowed_origins`
    3. The deployment's default tenant

    Cross-tenant requests are rejected. Credentials are bound to a
    single tenant at mint time.

    ## Credential tiers

    API clients carry one of three tiers — enforced per endpoint:

    | Tier | Reads | Cancel / Modify | Place orders |
    |---|---|---|---|
    | `readonly` | ✓ | ✗ | ✗ |
    | `liquidation` | ✓ | ✓ | ✗ |
    | `fullTrading` | ✓ | ✓ | ✓ |

    ## Idempotency

    Non-idempotent endpoints (POST/PATCH) accept an
    `Idempotency-Key` header (8–200 chars). Replay with the same
    key + identical payload returns the cached response (24h TTL).
    Same key + different payload returns 409.

    ## Pagination

    List endpoints use cursor pagination. Pass `?limit=<n>` (max 500,
    default 100) and `?cursor=<opaque>` from the previous response.
    Response carries `nextCursor` + `hasMore`.

    ## Rate limits

    Per-credential rate limits configured at admin level; surfaced
    in 429 responses with `Retry-After` header. Bootstrap path
    (`POST /api/auth/bootstrap`) is hard-limited to 10/min per apiKey.

  contact:
    name: Tron Charts API support
    email: hello@troncharts.xyz
  license:
    name: Proprietary
    url: https://troncharts.xyz/legal/api-terms

servers:
  - url: https://api.troncharts.xyz
    description: Production

tags:
  - name: Auth
    description: Bootstrap, bearer JWT issuance, session reads
  - name: Onboarding
    description: Public self-serve tenant signup — email-verify → provision (no auth)
  - name: OMS
    description: Order intent compose, modify, cancel, flatten, reverse
  - name: Accounts
    description: Account state, positions, orders, trades, fills, funding, create / update / lifecycle
  - name: Prop accounts
    description: Prop challenges — templates, evaluation, agree / cancel / claim
  - name: Users
    description: Trader identities — the owner an account belongs to (prop:manage scope)
  - name: Risk profiles
    description: Risk-profile CRUD + account binding (prop:manage scope)
  - name: Prop templates
    description: Challenge / funded template CRUD (prop:manage scope)
  - name: Trading groups
    description: Trading-group CRUD + account binding (prop:manage scope)
  - name: Firms
    description: Provider firm (prop-tenant) lifecycle — monitor + operate your own firms (firm:operate scope)
  - name: Market data
    description: Candles, depth, tape, quotes, mark stats
  - name: Trade journal
    description: Per-trade forensics — executions, timeline, metrics, samples, price context
  - name: Reports
    description: Historical reconciliation surface
  - name: SOR
    description: Smart Order Routing decision history + savings summary
  - name: Venues
    description: Venue capability discovery — adapters + asset classes + order types
  - name: KYC
    description: Per-user KYC verifications — initiate, status, vendor webhook receiver
  - name: Referrals
    description: Per-account referral code + claim ledger
  - name: Tenants
    description: >-
      Caller's own tenant config — aggregate + sliced reads
      (branding/theme/flags/venues/plan) and `tenant:config`-scoped writes
      (branding/theme/flags) for "bring-your-own-FE" partners
  # Prop firm / Trading rules / Webhooks tags were declared ahead of
  # their public /api/v1 paths — they render as empty "No endpoints available"
  # sections in Swagger UI, and the underlying surfaces are admin/internal
  # (/admin-api/*), which the public partner spec deliberately excludes. Re-add a
  # tag here only alongside the actual provider-facing operation(s) that use it.
  # Outbound webhook *payloads* are documented in asyncapi.yaml.
  - name: Earn
    description: Aave + Morpho deposit / withdraw calldata
  - name: Symbols
    description: Per-venue symbol discovery + metadata
  - name: Branding
    description: Tenant-scoped branding + theme + feature-flag reads
  - name: Transparency
    description: Public prop-firm transparency — on-chain custody proof + ledger reconcile, no auth
  - name: Meta
    description: Provider API discovery + version
  - name: MCP
    description: LLM agentic surface (Claude / ChatGPT)
  - name: Analytics
    description: Pre-digested trade-journal aggregates — performance, per-symbol, equity curve, risk metrics, prop diagnosis
  - name: Indicators
    description: CRUD over saved indicator presets (lab-flag gated)
  - name: Backtests
    description: Declarative no-RCE strategy backtester — run, list, fetch
  - name: Copy trading
    description: >-
      Self + social copy trading — subscription CRUD, the leader-consent
      profile, the public discovery board (leaderboard + trader profiles),
      and the perf-fee earnings / fees ledgers. Account-rooted + tenant-scoped;
      writes gated behind the `copyTrading` (and, for social, `copyTradingSocial`
      + leader opt-in) feature flags. Paths live under `/api/*`, not `/api/v1/*`.

paths:
  # ============================================================
  # AUTH
  # ============================================================

  /api/auth/bootstrap:
    post:
      tags: [Auth]
      summary: Bootstrap — mint short-lived WSS tokens
      description: |
        Verifies (`apiKey`, `apiSecret`) and mints single-use WSS tokens
        for each subsystem (Risk Engine, Quote Service) plus a 24h
        bearer JWT for REST calls. Rate limit: 10/min per apiKey.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BootstrapRequest'
      responses:
        '200':
          description: Success — tokens minted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BootstrapResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/auth/api-token:
    post:
      tags: [Auth]
      summary: OAuth token endpoint — mint / refresh a 24h bearer JWT
      description: |
        The OAuth 2.0 token endpoint advertised by the hosted MCP discovery
        documents (`/.well-known/oauth-authorization-server`). Returns a bearer
        JWT for `Authorization: Bearer <jwt>` PLUS a 30-day rotating refresh
        token. Accepts three request shapes:

        - **Legacy JSON** `{ apiKey, apiSecret }` (`application/json`) — existing
          SDK/CLI path, still supported.
        - **OAuth form** `grant_type=client_credentials` with `client_id`=apiKey
          + `client_secret`=apiSecret (`application/x-www-form-urlencoded`, via
          `client_secret_post` or HTTP Basic).
        - **OAuth form** `grant_type=refresh_token` with `refresh_token=<token>`
          — rotates: returns a fresh access token + a fresh refresh token and
          invalidates the presented one. Reuse of an already-rotated token is a
          replay and revokes the whole refresh family.

        The response carries both the legacy fields and the standard OAuth 2.0
        token-response fields so remote MCP connectors parse it natively.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ApiTokenRequest'
          application/x-www-form-urlencoded:
            schema:
              $ref: '#/components/schemas/ApiTokenOAuthRequest'
      responses:
        '200':
          description: Token minted / refreshed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiTokenResponse'
        '400':
          description: Malformed request / unsupported_grant_type
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/auth/api-token/revoke:
    post:
      tags: [Auth]
      summary: Revoke a bearer JWT
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }

  # ============================================================
  # ONBOARDING (public self-serve tenant signup — Self-Serve v2 Phase B)
  # ============================================================

  /api/public/tenants/signup:
    post:
      tags: [Onboarding]
      summary: Stage a self-serve tenant signup
      description: |
        Public — no auth. Stages a `pending` signup row and emails a
        single-use verification token; nothing is provisioned yet (an
        unverified email can't squat a slug). Reserved slugs, and slugs that
        are already live or already pending, are rejected.
        Rate-limited per-IP (10/hour); 429 with `Retry-After` past that.
        `verifyUrl` is only present in the response outside production — in
        production the token is delivered exclusively to the email inbox.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TenantSignupRequest' }
      responses:
        '201':
          description: Signup staged; verification email sent (best-effort)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantSignupResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          description: reserved_slug / slug_taken / signup_pending
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/public/tenants/verify:
    post:
      tags: [Onboarding]
      summary: Verify a signup token — provisions the tenant
      description: |
        Public — no auth. On first valid verification, creates the tenant +
        seeds the workspace + mints the first tenant-admin in ONE
        transaction, in that order: `tenants` insert → `provisionTenant` →
        `bootstrapTenantAdmin`. Idempotent re-verify of an already-provisioned
        token returns 409 with the resolved `tenantId`. Expired tokens flip to
        `expired` and 400. A slug claimed by someone else between signup and
        verify also expires the row and 409s. When
        `SELF_SERVE_FIRM_ISSUANCE_ENABLED` is set, best-effort mints a
        firm-operator API credential (`firm:operate` + `prop:manage`) — a mint
        failure does not fail the request; `operatorCredential` is `null` in
        that case (the secret is otherwise surfaced exactly once).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TenantVerifyRequest' }
      responses:
        '201':
          description: Tenant provisioned
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantVerifyResponse' }
        '400':
          description: invalid_body / invalid_token / expired
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: already_provisioned / slug_taken
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          $ref: '#/components/responses/RateLimited'

  # ============================================================
  # OMS
  # ============================================================

  /api/v1/oms/intents:
    post:
      tags: [OMS]
      summary: Place an order intent
      description: |
        Compose + sign + dispatch an order to the target venue. Single
        endpoint for market / limit / stop / stop_limit / take_profit
        across HL / Aster / Polymarket. Requires `fullTrading` tier.

        On success, the response contains the intent ID. The order's
        venue-side ID and fill events are pushed over the Risk Engine
        WSS. Subscribe to `intent.<intentId>` for live updates.

        Brackets: there is NO inline `bracket` field. Place the entry
        first, then attach OCO legs via `POST /oms/brackets/attach`
        (`{ parentIntentId, tpPrice, slPrice }`), or the Risk-Engine WS
        `Bracket-Insert` frame. (audit H7 — the old inline-bracket doc
        advertised a field the compose path never read.)
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/TenantSlug'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PlaceOrderRequest'
      responses:
        '200':
          description: Intent composed + dispatched
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlaceOrderResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/IdempotencyConflict'
        '422':
          $ref: '#/components/responses/VenueRejected'

  /api/v1/oms/cancel:
    post:
      tags: [OMS]
      summary: Cancel a working order
      description: Requires `liquidation` or `fullTrading` tier.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CancelOrderRequest'
      responses:
        '200':
          description: Cancel dispatched
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelOrderResponse'

  /api/v1/oms/modify:
    post:
      tags: [OMS]
      summary: Modify a working order (price / qty)
      description: Requires `fullTrading` tier.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ModifyOrderRequest'
      responses:
        '201':
          description: Modify intent composed and dispatched.
          content:
            application/json:
              schema:
                type: object
                required: [intentId]
                properties:
                  intentId: { type: string, format: uuid }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/oms/flatten:
    post:
      tags: [OMS]
      summary: Flatten — close every open position
      description: |
        Closes every open position on the account in one call. Optional
        symbol filter for single-symbol close. Requires `liquidation`
        or `fullTrading` tier.

        Ordering: the returned `intentIds` form an ordered batch — resting
        cancels first, then the reduce-only close. Sign/dispatch them in that
        order. A sign-result for the close while a cancel is still pre-dispatch
        is rejected with a retryable `batch_predecessor_pending` (409); retry
        once the cancels have dispatched.
      security:
        - BearerAuth: []
        - CookieAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                accountId: { type: string }
                symbol: { type: string, description: 'Optional — close only this symbol' }
      responses:
        '200':
          description: Flatten dispatched
          content:
            application/json:
              schema:
                type: object
                properties:
                  closedCount: { type: integer }
                  intentIds: { type: array, items: { type: string } }

  /api/v1/oms/reverse:
    post:
      tags: [OMS]
      summary: Reverse a position — close + re-open opposite side
      description: |
        Flattens the position then opens the opposite leg. The account is
        resolved from the caller's token; the optional `symbol` narrows the
        reverse to one market (omit to reverse the only open position).
        Requires `fullTrading` tier.

        Ordering: the batch dispatches in phases — cancels, then the reduce-only
        close, then the opposite-side open. Sign/dispatch in that order; an
        out-of-phase sign-result is rejected with a retryable
        `batch_predecessor_pending` (409).
      security:
        - BearerAuth: []
        - CookieAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                symbol: { type: string, description: 'Optional — reverse only this symbol' }
      responses:
        '200':
          description: Reverse dispatched
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  cancelIntentIds: { type: array, items: { type: string } }
                  closeIntentId: { type: string, nullable: true }
                  openIntentId:
                    type: string
                    nullable: true
                    description: >-
                      Always null in OMS Phase 1 — the open leg is DEFERRED and
                      composed at the REALIZED close fill size once the close
                      terminalizes (never a pre-close snapshot, which could
                      oversize the flip). See reverseOpenDeferred.
                  reverseOpenDeferred:
                    type: boolean
                    description: >-
                      True when the open leg is deferred (the normal reverse
                      path). The flip lands asynchronously via the reverse-open
                      chain and surfaces on the WS Position-Update frame.
                  alreadyFlat: { type: boolean }

  /api/v1/oms/cancel-all:
    post:
      tags: [OMS]
      summary: Cancel every working order in the account scope
      description: |
        Cancels all working orders for the caller's account (resolved from the
        token); the optional `symbol` narrows the cancel to one market. Requires
        `fullTrading` tier.
      security:
        - BearerAuth: []
        - CookieAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                symbol: { type: string, description: 'Optional — cancel only this symbol' }
      responses:
        '200':
          description: Cancels dispatched
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  cancelIntentIds: { type: array, items: { type: string } }

  /api/v1/oms/chase:
    post:
      tags: [OMS]
      summary: Chase a working order — atomic reduceOnly-preserving re-peg
      description: |
        Atomically CANCEL a working order and re-peg it to the current market as
        a MARKETABLE replacement (HL → slippage-bounded IOC limit, Aster →
        MARKET, paper → fills at the current mark), carrying the ORIGINAL order's
        `reduceOnly` + side + bracket/OCO linkage. A chase of a reduce-only closer
        STAYS reduce-only (the shared fill core clamps it to the open position →
        it can never over-fill or flip), and only the REMAINING unfilled size is
        re-pegged (a partial chase never double-fills). Cancel fires first and is
        stamped ahead of the replacement so the re-peg can't beat it. Live
        (HL/Aster) + paper. Errors: 404 `intent_not_found`, 403 `not_owned`,
        409 `not_working` / `nothing_to_chase`, 400 `venue_unsupported`.
        Requires `fullTrading` tier.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChaseRequest' }
      responses:
        '200':
          description: Order cancelled + re-pegged
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChaseResponse' }

  /api/v1/oms/brackets/attach:
    post:
      tags: [OMS]
      summary: Attach OCO take-profit / stop-loss legs to an entry
      description: |
        Attach a TP and/or SL bracket to an existing entry order. At least one
        of `tpPrice` / `slPrice` must be set. When the parent is still unfilled
        the legs are PARKED (`staged: true`) and arm automatically on parent fill
        (OCO-cancelled if it terminates unfilled); while staged they stream over
        `/ws/risk` as pending Open-Orders rows. Pass an `Idempotency-Key` to make
        retries safe (a second un-keyed attach on the same parent → 409
        `bracket_legs_exist`).
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AttachBracketsRequest' }
      responses:
        '200':
          description: Brackets attached (or staged)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AttachBracketsResponse' }

  /api/v1/oms/brackets/attach-to-position:
    post:
      tags: [OMS]
      summary: Attach OCO take-profit / stop-loss legs to an OPEN position
      description: |
        Attach TP and/or SL protective legs to an ALREADY-OPEN position (resolved
        from the caller's account + `symbol`). Both legs are composed reduce-only
        with a shared OCO GROUP id, so bracketLifecycle cancels the survivor when
        one fires AND cancels both when the position closes — closing the orphan
        protective-leg ghost (a drag-placed SL/TP was a standalone reduce-only
        intent with no OCO link, so the survivor re-opened exposure). At least one
        of `tpPrice` / `slPrice` must be set. The legs dispatch immediately (a
        position already exists). Live (HL/Aster) + paper. Errors:
        404 `no_position_for_symbol`, 400 `no_legs_requested` / `venue_unsupported`.
        Requires `fullTrading` tier.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AttachToPositionRequest' }
      responses:
        '200':
          description: Protective legs attached + OCO-grouped
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AttachToPositionResponse' }

  /api/v1/oms/brackets/modify:
    post:
      tags: [OMS]
      summary: Re-price an entry's still-staged TP/SL legs in place
      description: |
        In-place re-price of an entry's STILL-STAGED bracket legs — no archive +
        re-add, so the FE chart doesn't flicker. At least one of `tpPrice` /
        `slPrice` must be set, and each must sit on the correct side of the entry
        (buy: TP above / SL below, else 400 `invalid_price_side`). Errors:
        404 `parent_not_found` / `no_staged_legs`, 403 `parent_wallet_mismatch`,
        409 `parent_filled`, 501 `venue_unsupported` (paper-only today — fall
        back to cancel + `brackets/attach` for HL/Aster).
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ModifyBracketsRequest' }
      responses:
        '200':
          description: Staged legs re-priced in place
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModifyBracketsResponse' }

  # ============================================================
  # ACCOUNTS
  # ============================================================

  /api/v1/quote/attest:
    post:
      tags: [OMS]
      summary: Attest the quote an order will be signed against
      description: >-
        Returns a signed attestation envelope committing the backend to the price
        the trader was shown. Call it immediately before signing; the attestation
        rides into the order submission.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [venue, symbol, side, qty]
              properties:
                venue: { type: string, enum: [hyperliquid, aster, lighter, sim] }
                symbol: { type: string, minLength: 1, maxLength: 64 }
                side: { type: string, enum: [buy, sell] }
                qty: { type: number, exclusiveMinimum: 0 }
      responses:
        '200': { description: '{ attestation } — the signed envelope' }
        '400': { description: 'invalid_body / no_wallet_on_session' }
        '503': { description: 'No mark available to attest against' }

  /api/v1/accounts/{accountId}/state:
    get:
      tags: [Accounts]
      summary: Account state snapshot
      description: Balance + margin + equity + buying power across every venue the account holds.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdPath'
      responses:
        '200':
          description: Account state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccountState'

  /api/v1/accounts/{accountId}/positions:
    get:
      tags: [Accounts]
      summary: Open positions across venues
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdPath'
      responses:
        '200':
          description: Open positions
          content:
            application/json:
              schema:
                type: object
                properties:
                  positions: { type: array, items: { $ref: '#/components/schemas/Position' } }

  /api/v1/accounts/{accountId}/orders:
    get:
      tags: [Accounts]
      summary: Working + recent orders
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdPath'
        - $ref: '#/components/parameters/CursorLimit'
      responses:
        '200':
          description: Order list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderListResponse'

  /api/v1/accounts/{accountId}/trades:
    get:
      tags: [Accounts]
      summary: Closed round-trip trades
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdPath'
        - $ref: '#/components/parameters/CursorLimit'
        - $ref: '#/components/parameters/DateRange'
      responses:
        '200':
          description: Trade list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TradeListResponse'

  /api/v1/accounts/{accountId}/fills:
    get:
      tags: [Accounts]
      summary: Raw venue fills
      description: |
        Fill-level execution history. Each row carries `venue`,
        `venueOrderId` and `venueTradeId`.

        On a routed (A-book) fill those are the exchange's own identifiers
        and reconcile against the exchange's records. On an internalised
        (B-booked) fill `venue` is `paper` — the firm was the counterparty,
        the fill was matched by our engine, and no order was ever placed at
        an exchange. The two id fields are then ids WE minted for the
        internal match: stable and safe to dedup on, but they resolve
        nowhere off-platform. Both are nullable.

        `venue: "paper"` alone does not mean simulated money — it also
        covers internalised real-money accounts. See "Execution model" in
        the API description.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdPath'
      responses:
        '200': { description: Fill list }

  /api/v1/accounts/{accountId}/funding:
    get:
      tags: [Accounts]
      summary: Deposit / withdraw / transfer ledger
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdPath'
        - $ref: '#/components/parameters/CursorLimit'
      responses:
        '200':
          description: Funding events
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FundingListResponse'

  /api/v1/accounts/{accountId}/order-history:
    get:
      tags: [Accounts]
      summary: Historical orders for the account (cursor-paginated)
      security:
        - BearerAuth: []
      parameters:
        - { in: path, name: accountId, required: true, schema: { type: string } }
        - { in: query, name: venue, schema: { type: string } }
        - { in: query, name: symbol, schema: { type: string } }
        - { in: query, name: status, schema: { type: string } }
        - { in: query, name: from, schema: { type: string } }
        - { in: query, name: cursor, schema: { type: string } }
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
      responses:
        '200': { description: A page of historical orders }

  /api/v1/accounts/{accountId}/state/history:
    get:
      tags: [Accounts]
      summary: Time series of account-state snapshots
      security:
        - BearerAuth: []
      parameters:
        - { in: path, name: accountId, required: true, schema: { type: string } }
        - { in: query, name: venue, schema: { type: string } }
        - { in: query, name: from, schema: { type: string } }
        - { in: query, name: to, schema: { type: string } }
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 5000, default: 1000 } }
      responses:
        '200': { description: Snapshots ordered oldest to newest }
        '400': { description: 'invalid_query' }

  /api/v1/accounts/{accountId}/events:
    get:
      tags: [Accounts]
      summary: Account lifecycle event log
      security:
        - BearerAuth: []
      parameters:
        - { in: path, name: accountId, required: true, schema: { type: string } }
      responses:
        '200': { description: Lifecycle events for the account }

  /api/v1/accounts/{accountId}/manual-trades:
    post:
      tags: [Accounts]
      summary: Record a trade executed outside the platform
      description: >-
        Journal-only. Adds a manually entered trade so off-platform activity
        appears in reports; it dispatches nothing to a venue.
      security:
        - BearerAuth: []
      parameters:
        - { in: path, name: accountId, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [symbol, side, qty, entryTime]
              properties:
                symbol: { type: string, minLength: 1, maxLength: 64 }
                side: { type: string, enum: [long, short] }
                qty: { type: number, exclusiveMinimum: 0 }
                entryTime: { type: string, format: date-time }
      responses:
        '200': { description: The recorded trade }
        '400': { description: 'invalid_body / invalid_timestamps' }

  /api/v1/accounts/{accountId}/block:
    post:
      tags: [Accounts]
      summary: Block an account — suspend AND flatten every open position (prop:manage)
      description: |
        Suspends the account and immediately closes every open position it
        holds, then writes an `account.block` audit row.

        **This is not reversible.** `POST /unblock` lifts the suspension; it
        does not reopen anything, and the realised PnL from the flatten stands.
        For a reversible hold that leaves positions alone, use
        `PATCH /api/v1/accounts/{accountId}/suspension` instead.

        The response carries `flattened` (how many positions were closed) and,
        when any close failed, `errors`. Read them: a partial flatten leaves the
        account suspended with positions still open, which is the one state
        neither name describes.

        Requires the **`prop:manage`** scope and is hard-scoped to the caller's
        tenant. `accountId` accepts the UUID or the public account number.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdPath'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 280, description: 'Recorded on the audit row. Defaults to risk_engine_block.' }
      responses:
        '200':
          description: Account blocked
          content:
            application/json:
              schema:
                type: object
                required: [accountId, blocked, flattened]
                properties:
                  accountId: { type: string }
                  blocked: { type: boolean, enum: [true] }
                  flattened: { type: integer, description: 'Positions closed by this call.' }
                  errors:
                    type: array
                    description: 'Present only when a position failed to close — the account is suspended but NOT fully flat.'
                    items: { type: string }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/accounts/{accountId}/unblock:
    post:
      tags: [Accounts]
      summary: Lift a block or suspension (prop:manage)
      description: |
        Clears `suspendedAt` / `suspendedReason` so the account can trade again.

        It does **not** restore positions closed by a prior `block` — nothing
        can. Equivalent in effect to
        `PATCH /suspension { "suspended": false }`.

        Requires the **`prop:manage`** scope and is hard-scoped to the caller's
        tenant.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdPath'
      responses:
        '200':
          description: Suspension lifted
          content:
            application/json:
              schema:
                type: object
                required: [accountId, blocked]
                properties:
                  accountId: { type: string }
                  blocked: { type: boolean, enum: [false] }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/accounts/{accountId}/suspension:
    patch:
      tags: [Accounts]
      summary: Suspend / restore an account (prop:manage)
      description: |
        Firm-level lifecycle control (Volumetrica Enable/Disable parity).
        `suspended: true` suspends the account — it drops out of active reads
        and cannot trade or be activated; `false` restores it. **Reversible and
        non-destructive** — open positions are NOT liquidated (call the OMS
        flatten endpoint first if you need them closed). Idempotent.

        NOT the same operation as `POST /api/v1/accounts/{accountId}/block`,
        which suspends AND flattens every open position. That one is not
        reversible: `/unblock` lifts the suspension but does not restore the
        positions it closed. This endpoint was previously titled
        "Block / unblock", which made the two indistinguishable in the spec.

        Requires an API client carrying the **`prop:manage`** scope; the action
        is hard-scoped to the client's tenant (you can only block/unblock your
        own tenant's accounts). A cookie/user session without the scope is 403'd.
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [suspended]
              properties:
                suspended: { type: boolean }
                reason: { type: string, maxLength: 280 }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  suspendedAt: { type: string, format: date-time, nullable: true }
                  suspendedReason: { type: string, nullable: true }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          description: Missing the prop:manage scope
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: prop_manage_required }
        '404':
          description: Account not found in the caller's tenant
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: account_not_found }

  # ============================================================
  # ACCOUNT MANAGEMENT (create / update / lifecycle)
  # ============================================================

  /api/v1/accounts:
    get:
      tags: [Accounts]
      summary: List the caller's accounts
      description: |
        Every account owned by the calling user (paper + live), each with its
        bound wallets. Suspended (soft-closed) accounts are omitted. Bearer
        api-client tokens see the accounts under their service identity.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/TenantSlug'
      responses:
        '200':
          description: Account list
          content:
            application/json:
              schema:
                type: object
                properties:
                  accounts:
                    type: array
                    items: { $ref: '#/components/schemas/AccountSummary' }
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [Accounts]
      summary: Create an account
      description: |
        Create a `paper` or `live` account owned by the calling **user**
        session. (A bare api-client/bearer token has no owning user and is
        rejected with 403 `user_session_required`.) `linkPrimaryWallet` binds
        the session wallet 1:1 to a new live account; if that wallet already
        powers another account the create rolls back with 409
        `wallet_already_bound`.
      security:
        - CookieAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TenantSlug'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateAccountRequest' }
      responses:
        '200':
          description: Account created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccountSummary' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Wallet already bound to another account
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: wallet_already_bound }
                  detail: { type: string }
                  existingAccountId: { type: string }

  /api/v1/users:
    get:
      tags: [Users]
      summary: List trader identities
      description: |
        Traders in your tenant, newest first. Filter by `email` or your own
        `externalId`; page with the opaque `nextCursor`.
      security:
        - BearerAuth: []
      parameters:
        - { in: query, name: email, schema: { type: string } }
        - { in: query, name: externalId, schema: { type: string } }
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
        - { in: query, name: cursor, schema: { type: string } }
      responses:
        '200':
          description: Trader list
          content:
            application/json:
              schema:
                type: object
                properties:
                  users:
                    type: array
                    items: { $ref: '#/components/schemas/TraderUser' }
                  nextCursor: { type: string, nullable: true }
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Users]
      summary: Create a trader identity
      description: |
        Creates the **trader** — the person an account belongs to. The `id` it
        returns is the `ownerUserId` that `POST /api/v1/accounts/create-prop`
        requires.

        This is the first step of partner provisioning, because an api-client
        credential has no user session of its own: `POST /api/v1/accounts`
        rejects a bearer with 403 `user_session_required`, and `create-prop`
        needs an `ownerUserId` that already exists in your tenant.

        `ownerUserId` is the **trader**, not your tenant and not a prop firm.
        The tenant is never sent — it is forced from your credential.

        **Idempotent on `externalId`**: re-sending one you already used returns
        the existing trader with `idempotent: true` and HTTP 200 instead of
        creating a second identity. Use your own user id there so a retried
        provisioning run can't split one person across two identities.

        Creating a trader does not authenticate them. They still prove
        ownership by signing with their wallet or completing social auth; this
        endpoint only establishes the identity accounts hang off.
      security:
        - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TraderUserCreate' }
      responses:
        '201':
          description: Trader created
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: '#/components/schemas/TraderUser' }
        '200':
          description: A trader with this `externalId` already existed — returned unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: '#/components/schemas/TraderUser' }
                  idempotent: { type: boolean, enum: [true] }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: A trader with this email already exists in your tenant
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: email_already_exists }
                  detail: { type: string }
                  userId: { type: string }

  /api/v1/users/{id}:
    get:
      tags: [Users]
      summary: Get a trader identity
      security:
        - BearerAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        '200':
          description: Trader
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: '#/components/schemas/TraderUser' }
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Users]
      summary: Update a trader identity
      description: |
        Partial update of display name, lifecycle `status`, or CRM `tags`.

        `status` is the IDENTITY's standing and is **not** an order-time kill
        switch. To stop an account trading, block the account
        (`POST /api/v1/accounts/{accountId}/block`), which is what the
        enforcement path reads.

        There is no delete: `users` is the parent of accounts, wallets, orders
        and prop history. Retire a trader by setting `status` and blocking their
        accounts.
      security:
        - BearerAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TraderUserPatch' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: '#/components/schemas/TraderUser' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/users/{id}/accounts:
    get:
      tags: [Users]
      summary: List a trader's accounts
      description: Every non-suspended account owned by this trader, with its group and profile bindings.
      security:
        - BearerAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        '200':
          description: Accounts owned by the trader
          content:
            application/json:
              schema:
                type: object
                properties:
                  accounts:
                    type: array
                    items: { type: object, additionalProperties: true }
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/accounts/create-prop:
    post:
      tags: [Prop accounts]
      summary: Create an account + prop challenge atomically
      description: |
        One-shot: provisions a trading account and starts a prop challenge from
        a template in a single call. `mode=paper` activates instantly; `mode=live`
        creates a `pending_payment`/`pending_agreement` prop account. Returns
        404 for an unknown template / prop firm, 409 `wallet_already_bound`, or
        503 `prop_tenant_undeployed`.

        A user session owns the created account. An API-client credential may
        provision on behalf of a user via `ownerUserId` (a user in its tenant) —
        this requires the `prop:manage` scope (400 `owner_user_required` /
        404 `owner_user_not_found` otherwise).
      security:
        - CookieAuth: []
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TenantSlug'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreatePropRequest' }
      responses:
        '200':
          description: Account + prop account created
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                description: The created account and prop-account records.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/sandbox/paper-account:
    post:
      tags: [Prop accounts]
      summary: Provision a paper prop account (partner sandbox)
      description: |
        One-call self-serve provisioning for integration validation. With a
        `fullTrading` API-client credential (no `prop:manage` scope needed),
        idempotently ensures a paper-shadow prop firm + risk profile + trading
        group + paper template for the caller's tenant, creates a paper prop
        account, and (by default) seeds one demo position. The new account is
        linked into the calling credential's ownership scope, so it appears in
        `GET /api/v1/accounts` and its `state` / `positions` are readable by
        this credential. After it returns, (re)subscribe on the risk WS
        (endpoint from `/api/auth/bootstrap`) — the owned-account snapshot
        includes the new account; set it as your active account to receive its
        live `Account-State-Update` / `Balance-Update` / `Margin-Update` frames.

        Gated per deployment — returns 404
        when the feature is off. Requires an API-client bearer (403
        `api_client_required` for cookie sessions) at `fullTrading` tier (403
        `tier_insufficient` otherwise).
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TenantSlug'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SandboxPaperAccountRequest' }
      responses:
        '201':
          description: Paper prop account provisioned
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SandboxPaperAccountResult' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '402':
          description: A card on file is required (Stripe billing configured)
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          description: Provisioning could not complete (e.g. template/tenant invariant)

  /api/v1/accounts/{accountId}:
    get:
      tags: [Accounts]
      summary: Read account metadata
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdPath'
      responses:
        '200':
          description: Account
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccountSummary' }
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Accounts]
      summary: Update account metadata
      description: Owner-only. At least one field required.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdPath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PatchAccountRequest' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  accountId: { type: string }
                  updated:
                    type: array
                    items: { type: string }
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Accounts]
      summary: Soft-close an account
      description: |
        Reversible soft-close — flips `suspendedAt` so the account drops out of
        the active list and can no longer trade; the row is retained for audit
        and is **not** liquidated. Idempotent (already-closed → 200). The last
        remaining active account cannot be closed (409 `cannot_delete_last_account`).
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdPath'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 280 }
      responses:
        '200':
          description: Soft-closed
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Cannot close the last active account
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: cannot_delete_last_account }

  # ============================================================
  # PROP ACCOUNTS (challenges / evaluation / payouts)
  # ============================================================

  /api/v1/prop-accounts/templates:
    get:
      tags: [Prop accounts]
      summary: List active challenge templates
      description: Cross-tenant catalog of buyable challenge/funded templates.
      security:
        - BearerAuth: []
        - CookieAuth: []
      responses:
        '200':
          description: Templates
          content:
            application/json:
              schema:
                type: object
                properties:
                  templates:
                    type: array
                    items: { $ref: '#/components/schemas/PropTemplate' }

  /api/v1/prop-accounts:
    get:
      tags: [Prop accounts]
      summary: List the caller's prop accounts
      security:
        - BearerAuth: []
        - CookieAuth: []
      responses:
        '200':
          description: Prop accounts
          content:
            application/json:
              schema:
                type: object
                properties:
                  propAccounts:
                    type: array
                    items: { $ref: '#/components/schemas/PropAccount' }
    post:
      tags: [Prop accounts]
      summary: Start a prop challenge from a template
      description: |
        Creates a prop account bound to the calling account from an active
        template. The mode (paper/live) is derived from the session wallet and
        gated against the template's `supportedModes`.
      security:
        - BearerAuth: []
        - CookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [templateId]
              properties:
                templateId: { type: string }
      responses:
        '201':
          description: Prop account created
          content:
            application/json:
              schema:
                type: object
                properties:
                  propAccount: { $ref: '#/components/schemas/PropAccount' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/prop-accounts/{propAccountId}:
    get:
      tags: [Prop accounts]
      summary: Read a prop account (with live rule results)
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/PropAccountIdPath'
      responses:
        '200':
          description: Prop account + evaluation
          content:
            application/json:
              schema:
                type: object
                properties:
                  propAccount: { $ref: '#/components/schemas/PropAccount' }
                  results: { type: array, items: { type: object, additionalProperties: true } }
                  breakdown: { type: object, additionalProperties: true }
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/prop-accounts/{propAccountId}/events:
    get:
      tags: [Prop accounts]
      summary: Prop account event timeline
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/PropAccountIdPath'
        - $ref: '#/components/parameters/CursorLimit'
      responses:
        '200':
          description: Events
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items:
                      type: object
                      description: >-
                        Prop-account lifecycle event; `payload` shape varies by
                        event type. Forfeiture settlement events (breach / quit /
                        expiry) carry `payload.platformCut`. A legacy alias key
                        with the same value is still emitted for pre-migration
                        consumers; read `platformCut`.
                      additionalProperties: true
                      properties:
                        payload:
                          type: object
                          additionalProperties: true
                          properties:
                            platformCut:
                              type: string
                              description: Platform share of the forfeiture carve (USDC base units), on breach / quit / expiry settlement events.
                  nextCursor: { type: string, nullable: true }
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/prop-accounts/{propAccountId}/agree:
    post:
      tags: [Prop accounts]
      summary: Accept the prop agreement (activate)
      description: Transitions `pending_agreement` → `active`. 409 from any other status.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/PropAccountIdPath'
      responses:
        '200':
          description: Activated
          content:
            application/json:
              schema:
                type: object
                properties:
                  propAccount: { $ref: '#/components/schemas/PropAccount' }
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Invalid state transition
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: invalid_state }

  /api/v1/prop-accounts/{propAccountId}/cancel:
    post:
      tags: [Prop accounts]
      summary: Cancel a prop account
      description: Cancels any non-terminal prop account.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/PropAccountIdPath'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string }
      responses:
        '200':
          description: Cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  propAccount: { $ref: '#/components/schemas/PropAccount' }
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/prop-accounts/{propAccountId}/claim:
    post:
      tags: [Prop accounts]
      summary: Claim a payout
      description: |
        Claim the eligible payout for a `passed` prop account. Subject to daily
        caps + the firm's payout policy. Returns a signed EIP-712 permit for
        on-chain settlement where applicable.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/PropAccountIdPath'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Payout claimed
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/prop-accounts/{propAccountId}/reset:
    post:
      tags: [Prop accounts]
      summary: Reset a paper prop account's evaluation (prop:manage)
      description: |
        Wipes the simulated balance + positions back to the account size, clears
        any breach / enforcement posture, and re-activates the account so the
        evaluation starts over. **Paper-mode only** — a live/funded account
        rejects with 400 `not_paper`. Requires the **`prop:manage`** scope; hard
        tenant-scoped. Resettable from `active` / `breached` / `expired`.
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/PropAccountIdPath'
      responses:
        '200':
          description: Reset
          content:
            application/json:
              schema:
                type: object
                properties:
                  propAccount: { $ref: '#/components/schemas/PropAccount' }
        '400':
          description: Not a paper account
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: not_paper }
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not resettable from the current status
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: not_resettable }

  /api/v1/prop-accounts/{propAccountId}/promote:
    post:
      tags: [Prop accounts]
      summary: Force-promote to the next tier / funded (prop:manage)
      description: |
        Creates the next tier (or funded) account **regardless of pass
        conditions** — an explicit operator/partner override (e.g. manual review
        approval) — and marks this account terminal. Reuses the same promotion
        logic as the automatic pass path. Requires **`prop:manage`**; hard
        tenant-scoped. 409 `no_next_tier` when the account is at the top of its
        ladder / has no next-step template.
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/PropAccountIdPath'
      responses:
        '200':
          description: Promoted
          content:
            application/json:
              schema:
                type: object
                properties:
                  promoted: { type: boolean }
                  childPropAccountId: { type: string }
                  propAccount: { $ref: '#/components/schemas/PropAccount' }
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: No next tier to promote to
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: no_next_tier }

  /api/prop/deposit-calldata:
    post:
      tags: [Prop accounts]
      summary: Compose deposit calldata for a challenge entry
      description: |
        Composes calldata for `PropInstance.depositForChallenge`. The FE
        wallet signs + broadcasts — the BE never broadcasts here, keeping the
        trader in control of the fund-moving step. Caller must own the
        `propAccount` (session `accountId` match, else 403). Requires the
        account's tenant to be a `live` prop tenant with a deployed
        PropInstance and the account status `pending_payment`. On-chain event
        pickup (`claimWatcher`) flips status to `active` once the deposit
        confirms. When the collateral token can be resolved for the tenant's
        chain, returns the full ordered `[approve?, deposit]` call batch
        (`includeApprove` defaults `true`); otherwise falls back to the
        single deposit call.
      security:
        - BearerAuth: []
        - CookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PropDepositCalldataRequest' }
      responses:
        '200':
          description: Deposit calldata
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PropDepositCalldataResponse' }
        '400':
          description: invalid_body / not_a_live_tenant / invalid_status_for_deposit / template_missing
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          description: instance_not_deployed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ============================================================
  # PROP TRANSPARENCY (E3 — public custody proof, no auth)
  # ============================================================

  /api/prop/payout-calldata:
    post:
      tags: [Prop accounts]
      summary: Compose calldata for PropInstance.requestPayout
      description: >-
        Creates a PENDING payout request on-chain. It does not settle — the
        tenant multisig must call approvePayout(payoutId) separately.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [propAccountId, amountUsdcUnits]
              properties:
                propAccountId: { type: string, format: uuid }
                amountUsdcUnits: { type: string, pattern: '^\\d+$', description: Base units, not decimal USDC }
      responses:
        '200': { description: Unsigned calldata for the multisig to broadcast }
        '400': { description: 'invalid_body' }
        '403': { description: 'forbidden — the prop account is not yours' }
        '404': { description: 'not_found' }

  /api/prop/transparency:
    get:
      tags: [Transparency]
      summary: Every live firm's public transparency bundle
      description: |
        Public — no auth. For each `live` prop firm: on-chain addresses (+
        block-explorer links), the ledger totals (pay-ins / payouts /
        forfeits), the live on-chain treasury, a solvency classification, and
        the money-reconcile bundle comparing ledger expectation to on-chain
        reality — so anyone can verify a firm's custody without trusting the
        platform. Response is cached 30s server-side (bounds the on-chain
        read fan-out regardless of caller rate); capped at 200 firms.
      responses:
        '200':
          description: Transparency bundles
          content:
            application/json:
              schema:
                type: object
                properties:
                  firms:
                    type: array
                    items: { $ref: '#/components/schemas/FirmTransparency' }

  /api/prop/transparency/{firmId}:
    get:
      tags: [Transparency]
      summary: One live firm's public transparency bundle
      description: Public — no auth. 404 if the firm doesn't exist or isn't `live`.
      parameters:
        - in: path
          name: firmId
          required: true
          schema: { type: string, maxLength: 64 }
      responses:
        '200':
          description: Transparency bundle
          content:
            application/json:
              schema:
                type: object
                properties:
                  firm: { $ref: '#/components/schemas/FirmTransparency' }
        '404':
          $ref: '#/components/responses/NotFound'

  # ============================================================
  # RISK PROFILES (prop:manage)
  # ============================================================

  /api/v1/firms:
    get:
      tags: [Firms]
      summary: List firms
      description: |
        Every firm (prop-tenant) owned by the caller's tenant, newest first.
        Requires the **`firm:operate`** scope. Read-only monitor surface
        (Track D, D3.1); the write lifecycle lands in later slices.
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Firms
          content:
            application/json:
              schema:
                type: object
                properties:
                  firms:
                    type: array
                    items: { $ref: '#/components/schemas/Firm' }
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Firms]
      summary: Create a firm
      description: |
        Create a live firm under the caller's OWN tenant (auto-seeds starter
        challenge templates + an optional paper-shadow sibling). The firm starts
        `pending_deploy` — deploy is a separate step. `tenantId` is FORCED from
        the credential's session; any tenantId in the body is ignored. Requires
        the **`firm:operate`** scope.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FirmCreate' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  firm: { $ref: '#/components/schemas/Firm' }
                  paperShadowId: { type: string, nullable: true }
                  seededTemplates:
                    type: array
                    items: { type: object, additionalProperties: true }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v1/firms/{id}:
    get:
      tags: [Firms]
      summary: Get a firm
      description: |
        One firm owned by the caller's tenant. Requires **`firm:operate`**.
        Returns **404** for a firm that doesn't exist OR belongs to another
        tenant (no cross-tenant existence oracle).
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Firm
          content:
            application/json:
              schema:
                type: object
                properties:
                  firm: { $ref: '#/components/schemas/Firm' }
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/firms/{id}/payouts/pending:
    get:
      tags: [Firms]
      summary: List a firm's pending payouts
      description: |
        The firm's `passed` accounts whose owed payout exceeds paid — the
        multisig / claim action list. Requires **`firm:operate`**. Returns
        **404** if the firm isn't owned by the caller's tenant.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Pending payouts
          content:
            application/json:
              schema:
                type: object
                properties:
                  pendingPayouts:
                    type: array
                    items:
                      type: object
                      properties:
                        propAccountId: { type: string }
                        accountId: { type: string }
                        walletAddress: { type: string, nullable: true }
                        templateName: { type: string, nullable: true }
                        owedUsd: { type: string, nullable: true }
                        paidUsd: { type: string, nullable: true }
                        outstandingUsd: { type: string }
                        startedAt: { type: string, format: date-time, nullable: true }
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/firms/{id}/payouts/requests:
    get:
      tags: [Firms]
      summary: List a firm's payout requests (with decisions)
      description: |
        Richer companion to payouts/pending — every `passed` account with an
        outstanding payout, annotated with any decision already recorded
        (`decision` null = still actionable). Requires **`firm:operate`**.
        **404** if the firm isn't owned by the caller's tenant.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Payout requests
          content:
            application/json:
              schema:
                type: object
                properties:
                  requests:
                    type: array
                    items:
                      type: object
                      properties:
                        propAccountId: { type: string }
                        accountId: { type: string }
                        walletAddress: { type: string, nullable: true }
                        templateName: { type: string, nullable: true }
                        owedUsd: { type: string, nullable: true }
                        paidUsd: { type: string, nullable: true }
                        outstandingUsd: { type: string }
                        startedAt: { type: string, format: date-time, nullable: true }
                        decision: { type: string, enum: [approved, rejected], nullable: true }
                        decisionAmountUsd: { type: string, nullable: true }
                        decisionReason: { type: string, nullable: true }
                        decidedBy: { type: string, nullable: true }
                        decidedAt: { type: string, nullable: true }
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/firms/{id}/payouts/{propAccountId}/decision:
    post:
      tags: [Firms]
      summary: Approve or reject a pending payout
      description: |
        Approve or reject a `passed` account's outstanding payout. Approval
        RECORDS + QUEUES the payout (on-chain settle pending contract deploy) —
        it does NOT dispatch on-chain. `amountUsd` defaults to the full
        outstanding and must not exceed it. Requires **`firm:operate`**. Double
        tenant-scoped (firm owned + account belongs to it). **404** if not owned.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - in: path
          name: propAccountId
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision: { type: string, enum: [approved, rejected] }
                amountUsd: { type: number, minimum: 0 }
                reason: { type: string, maxLength: 500 }
      responses:
        '200':
          description: Decision recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  propAccount: { type: object, additionalProperties: true }
                  approval:
                    type: object
                    properties:
                      decision: { type: string, enum: [approved, rejected] }
                      amountUsd: { type: string }
                      reason: { type: string, nullable: true }
                      decidedBy: { type: string }
                      decidedAt: { type: string }
                      onChainStatus: { type: string }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Not awaiting payout / already decided / nothing outstanding.

  /api/v1/firms/{id}/readiness:
    get:
      tags: [Firms]
      summary: Firm go-live readiness
      description: |
        The firm's go-live readiness checklist — configured keys, observer
        whitelist, PropInstance deployment, event-watcher subscription, and
        vault funding. `ready` is true only when every required check passes.
        On-chain reads are RPC-tolerant (unknown ≠ failed). Requires
        **`firm:operate`**. Optional `?target=` selects the deploy chain.
        **404** if the firm isn't owned by the caller's tenant.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - in: query
          name: target
          schema: { type: string }
      responses:
        '200':
          description: Readiness
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FirmReadiness' }
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/firms/{id}/deploy:
    post:
      tags: [Firms]
      summary: Deploy a firm's on-chain PropInstance (one-click)
      description: |
        One-click deploy via the platform gas-only deployer key — composes the
        factory calldata, preflights the observer whitelist + existing-instance
        state, broadcasts, then persists the instance address and subscribes the
        event watcher (the firm flips to `active`). Pass `dryRun: true` to
        preview calldata + preflight without broadcasting. Requires
        **`firm:operate`**. The firm must be `live` + `pending_deploy` with a
        multisig configured. **404** if not owned by the caller's tenant.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FirmDeployBody' }
      responses:
        '200':
          description: Deployed, or a dry-run preview when dryRun is set.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                description: |
                  Broadcast → `{ deployed: true, firm, instanceAddress, txHash,
                  chainId, target, observer, whitelisted }`. Dry-run →
                  `{ dryRun: true, to, data, chainId, target, observer,
                  tenantIdOnChain, whitelisted, alreadyDeployed, … }`.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Precondition not met on-chain (observer not whitelisted, or already deployed).
        '502':
          description: The deploy broadcast failed / reverted.
        '503':
          description: Factory / observer / deployer key not configured.

  /api/v1/firms/{id}/status:
    patch:
      tags: [Firms]
      summary: Change a firm's status
      description: |
        Go-live / pause / resume / migrate the firm. Requires **`firm:operate`**.
        **404** if not owned by the caller's tenant.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [pending_deploy, active, paused, migrated] }
      responses:
        '200':
          description: Updated firm
          content:
            application/json:
              schema:
                type: object
                properties:
                  firm: { $ref: '#/components/schemas/Firm' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/firms/{id}/hard-enforcement:
    patch:
      tags: [Firms]
      summary: Set a firm's hard-enforcement override
      description: |
        Per-firm override of the global hard-enforcement default — `true`/`false`
        override it, `null` falls back to the env default. Requires
        **`firm:operate`**. **404** if not owned.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hardEnforcement]
              properties:
                hardEnforcement: { type: boolean, nullable: true }
      responses:
        '200':
          description: Updated firm
          content:
            application/json:
              schema:
                type: object
                properties:
                  firm: { $ref: '#/components/schemas/Firm' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/risk-profiles:
    get:
      tags: [Risk profiles]
      summary: List risk profiles
      description: |
        The caller's tenant's risk profiles. Requires the **`prop:manage`** scope.
        Pass `?archived=true` to include archived profiles.
      security:
        - BearerAuth: []
      parameters:
        - in: query
          name: archived
          schema: { type: boolean }
      responses:
        '200':
          description: Profiles
          content:
            application/json:
              schema:
                type: object
                properties:
                  profiles:
                    type: array
                    items: { $ref: '#/components/schemas/RiskProfile' }
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Risk profiles]
      summary: Create a risk profile
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RiskProfileWrite' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  profile: { $ref: '#/components/schemas/RiskProfile' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v1/risk-profiles/validate:
    post:
      tags: [Risk profiles]
      summary: Validate a rule set without saving
      description: |
        Volumetrica `/tradingRule/validate` parity — check a rule set against the
        catalog before a create/update round-trip. Always **200** with a verdict
        (`valid:false` + the issues when the rules don't conform). No tenant
        read/write. Requires **`prop:manage`**.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [rules]
              properties:
                kind: { type: string, enum: [trader, challenge, funded] }
                rules:
                  type: array
                  maxItems: 64
                  items: { type: object, additionalProperties: true }
      responses:
        '200':
          description: Validation verdict
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
                  ruleCount: { type: integer }
                  issues:
                    type: array
                    items: { type: object, additionalProperties: true }
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v1/risk-profiles/{id}:
    patch:
      tags: [Risk profiles]
      summary: Update a risk profile
      description: |
        Partial update. Changing rules invalidates every bound account's gate cache.

        Authored profiles only. A profile with `managedByTemplateId` set is owned
        by that prop template — edit it via `PATCH /api/v1/prop-templates/{id}`
        with new `rules`.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RiskProfileWrite' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  profile: { $ref: '#/components/schemas/RiskProfile' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/ManagedByTemplate'

  /api/v1/risk-profiles/{id}/archive:
    post:
      tags: [Risk profiles]
      summary: Archive a risk profile
      description: |
        Soft-archives (sets active=false). Existing bindings stop being enforced.

        Authored profiles only. A managed profile is archived by deactivating
        its prop template (`DELETE /api/v1/prop-templates/{id}`) — archiving it
        directly would strand the template on a dead binding.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Archived
          content:
            application/json:
              schema:
                type: object
                properties:
                  profile: { $ref: '#/components/schemas/RiskProfile' }
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/ManagedByTemplate'

  /api/v1/risk-profiles/{id}/bind:
    post:
      tags: [Risk profiles]
      summary: Bind / unbind a profile to an account
      description: |
        Sets `accounts.risk_profile_id` so the order-time gate enforces the
        profile's rules. Both the profile and the account must be in the caller's
        tenant. **Unbind** by using the literal path id `none`.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
          description: A profile id, or the literal `none` to unbind.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string }
      responses:
        '200':
          description: Bound / unbound
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  accountId: { type: string }
                  riskProfileId: { type: string, nullable: true }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  # ============================================================
  # PROP TEMPLATES (prop:manage)
  # ============================================================

  /api/v1/prop-templates:
    get:
      tags: [Prop templates]
      summary: List challenge templates
      description: Templates for the caller's prop firms. Requires **`prop:manage`**. `?activeOnly=true` to filter.
      security:
        - BearerAuth: []
      parameters:
        - in: query
          name: activeOnly
          schema: { type: boolean }
      responses:
        '200':
          description: Templates
          content:
            application/json:
              schema:
                type: object
                properties:
                  templates:
                    type: array
                    items: { $ref: '#/components/schemas/PropTemplate' }
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Prop templates]
      summary: Create a challenge template
      description: |
        Same validation as the admin console: a `tradingGroupId` is **required**,
        a bound `riskProfileId` must be a `challenge`/`funded` profile in your
        tenant, and `nextStepTemplateId` must be same-firm + acyclic (mutually
        exclusive with an inline `tierLadder`).
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PropTemplateWrite' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  template: { $ref: '#/components/schemas/PropTemplate' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: A template with that id already exists
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: already_exists }

  /api/v1/prop-templates/{id}:
    get:
      tags: [Prop templates]
      summary: Read a template
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Template
          content:
            application/json:
              schema:
                type: object
                properties:
                  template: { $ref: '#/components/schemas/PropTemplate' }
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [Prop templates]
      summary: Update a template
      description: Partial update (all fields optional). Same validation as create.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PropTemplateWrite' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  template: { $ref: '#/components/schemas/PropTemplate' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Prop templates]
      summary: Soft-delete (deactivate) a template
      description: Sets `active=false`. In-flight prop accounts keep their frozen ruleset. Idempotent.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deactivated
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  alreadyInactive: { type: boolean }
        '404':
          $ref: '#/components/responses/NotFound'

  # ============================================================
  # TRADING GROUPS (prop:manage)
  # ============================================================

  /api/v1/trading-groups:
    get:
      tags: [Trading groups]
      summary: List trading groups
      description: The caller's tenant's groups (with account counts). Requires **`prop:manage`**.
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Groups
          content:
            application/json:
              schema:
                type: object
                properties:
                  groups:
                    type: array
                    items: { $ref: '#/components/schemas/TradingGroup' }
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags: [Trading groups]
      summary: Create a trading group
      description: A group scopes fees / leverage / venue+symbol allowlists / risk limits. `slug` is unique per tenant.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TradingGroupCreate' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  group: { $ref: '#/components/schemas/TradingGroup' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Slug already taken in this tenant
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: slug_taken }

  /api/v1/trading-groups/{id}:
    get:
      tags: [Trading groups]
      summary: Read a trading group
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Group
          content:
            application/json:
              schema:
                type: object
                properties:
                  group: { $ref: '#/components/schemas/TradingGroup' }
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Trading groups]
      summary: Update a trading group
      description: Partial update. `config` is replaced wholesale when present.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TradingGroupPatch' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  group: { $ref: '#/components/schemas/TradingGroup' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Trading groups]
      summary: Delete a trading group
      description: Members are reparented to the tenant default first. The default group cannot be deleted.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
        '400':
          description: Cannot delete the default group
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: cannot_delete_default }
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/trading-groups/{id}/make-default:
    post:
      tags: [Trading groups]
      summary: Make this the tenant default group
      description: Atomic flip — the prior default is demoted. New accounts inherit the default.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Default set
          content:
            application/json:
              schema:
                type: object
                properties:
                  group: { $ref: '#/components/schemas/TradingGroup' }
                  alreadyDefault: { type: boolean }
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/trading-groups/{id}/bind:
    post:
      tags: [Trading groups]
      summary: Bind / unbind an account to a trading group
      description: |
        Sets `accounts.trading_group_id` (the fees / leverage / venue+symbol scope
        the account composes against). Both group and account must be in the
        caller's tenant. **Unbind** with the literal path id `none` (the account
        then inherits the tenant default group).
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
          description: A group id, or the literal `none` to unbind.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string }
      responses:
        '200':
          description: Bound / unbound
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  accountId: { type: string }
                  tradingGroupId: { type: string, nullable: true }
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  # ============================================================
  # MARKET DATA
  # ============================================================

  /api/v1/market/candles/{venue}/{symbol}:
    get:
      tags: [Market data]
      summary: OHLCV candle snapshot
      security:
        - BearerAuth: []
      parameters:
        - { in: path, name: venue, required: true, schema: { $ref: '#/components/schemas/Venue' } }
        - { in: path, name: symbol, required: true, schema: { type: string } }
        - { in: query, name: interval, schema: { type: string, default: '1m' } }
        - { in: query, name: limit, schema: { type: integer, default: 200, maximum: 1000 } }
      responses:
        '200':
          description: Candles
          content:
            application/json:
              schema:
                type: object
                properties:
                  candles:
                    type: array
                    items:
                      $ref: '#/components/schemas/Candle'
        '403': { description: 'venue_disabled / symbol_disabled — the venue, or this product, is disabled by admin; market_closed — the futures (.FUT) market session is currently closed' }

  /api/v1/market/depth/{venue}/{symbol}:
    get:
      tags: [Market data]
      summary: Order book depth snapshot
      description: >-
        The venue's native top-of-book — Hyperliquid streams only the ~20
        tightest levels. For the deep book, use the /grouped variant.
      security:
        - BearerAuth: []
      parameters:
        - { in: path, name: venue, required: true, schema: { $ref: '#/components/schemas/Venue' } }
        - { in: path, name: symbol, required: true, schema: { type: string } }
        - { in: query, name: levels, schema: { type: integer, default: 20, maximum: 50 } }
      responses:
        '200': { description: Depth snapshot }
        '403': { description: 'venue_disabled / symbol_disabled — the venue, or this product, is disabled by admin; market_closed — the futures (.FUT) market session is currently closed' }
        '503': { description: cold_cache — subscription opened; retry in ~500ms }

  /api/v1/market/depth/{venue}/{symbol}/grouped:
    get:
      tags: [Market data]
      summary: Deep order book — significant-figure aggregated (HL core perps)
      description: >-
        A venue's native l2Book is shallow (~20 tightest levels). This returns
        Hyperliquid's significant-figure-aggregated deep book — the levels the
        exchange UI shows. nSigFigs 2-5 sets the bucket coarseness (nSigFigs=2
        buckets BTC into ~$1000 levels spanning ±$10k); mantissa (1/2/5)
        refines it and is only honored at nSigFigs=5. HL core perps only;
        coalesced server-side (~750ms cache).
      security:
        - BearerAuth: []
      parameters:
        - { in: path, name: venue, required: true, schema: { $ref: '#/components/schemas/Venue' } }
        - { in: path, name: symbol, required: true, schema: { type: string } }
        - { in: query, name: nSigFigs, required: true, schema: { type: integer, enum: [2, 3, 4, 5] } }
        - { in: query, name: mantissa, schema: { type: integer, enum: [1, 2, 5] }, description: Only honored at nSigFigs=5. }
      responses:
        '200': { description: Grouped depth snapshot }
        '400': { description: invalid nSigFigs, unsupported venue, or non-core symbol }
        '403': { description: 'venue_disabled / symbol_disabled — the venue, or this product, is disabled by admin; market_closed — the futures (.FUT) market session is currently closed' }
        '502': { description: upstream l2Book fetch failed }

  /api/v1/market/tape:
    get:
      tags: [Market data]
      summary: Recent trades tape
      security:
        - BearerAuth: []
      parameters:
        - { in: query, name: venue, required: true, schema: { $ref: '#/components/schemas/Venue' } }
        - { in: query, name: symbol, required: true, schema: { type: string } }
        - { in: query, name: limit, schema: { type: integer, default: 100, maximum: 500 } }
      responses:
        '200': { description: Recent prints }
        '403': { description: 'venue_disabled / symbol_disabled — the venue, or this product, is disabled by admin; market_closed — the futures (.FUT) market session is currently closed' }

  /api/v1/market/scan:
    get:
      tags: [Market data]
      summary: Cross-symbol scanner — rank a venue's whole symbol universe
      description: >-
        Fans out over the venue's symbol universe and ranks it. Heavier than a
        single /market/stats call, so anonymous callers are throttled harder; an
        authenticated cookie or bearer session is exempt.
      security:
        - BearerAuth: []
      parameters:
        - { in: query, name: venue, schema: { type: string, enum: [hyperliquid, aster], default: hyperliquid } }
        - { in: query, name: sortBy, schema: { type: string, enum: [change24h, volume, funding, openInterest, price, rsi], default: volume } }
        - { in: query, name: order, schema: { type: string, enum: [asc, desc], default: desc } }
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
        - { in: query, name: minVolumeUsd, schema: { type: number, minimum: 0 } }
        - { in: query, name: rsiBelow, schema: { type: number, minimum: 0, maximum: 100 } }
        - { in: query, name: rsiAbove, schema: { type: number, minimum: 0, maximum: 100 } }
      responses:
        '200': { description: Ranked rows for the venue }
        '400': { description: 'invalid_query' }
        '429': { description: 'rate_limited — anonymous callers only' }

  /api/v1/market/stats:
    get:
      tags: [Market data]
      summary: Mark price + 24h stats + funding rate
      security:
        - BearerAuth: []
      parameters:
        - { in: query, name: venue, required: true, schema: { $ref: '#/components/schemas/Venue' } }
        - { in: query, name: symbol, required: true, schema: { type: string } }
      responses:
        '200': { description: Market stats }

  /api/v1/market/quotes:
    get:
      tags: [Market data]
      summary: Quote cache snapshot — multi-venue
      security:
        - BearerAuth: []
      responses:
        '200': { description: Cached quotes }

  /api/v1/market/fx:
    get:
      tags: [Market data]
      summary: USD↔BRL FX rate (BE-canonical)
      description: >-
        Platform USDBRL rate derived from the B3 WDO future's mark
        (WDOFUT.B3 / 1000; DOLFUT fallback; persisted last-known rate
        off-hours — always flagged stale). Used to convert between USD- and
        BRL-denominated account ledgers. `usdbrl` is null only when the
        server has never observed a rate from any source.
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Current rate snapshot
          content:
            application/json:
              schema:
                type: object
                properties:
                  usdbrl:
                    type: object
                    nullable: true
                    properties:
                      rate: { type: number, example: 5.4455 }
                      ts: { type: integer, description: 'ms epoch the rate was observed' }
                      stale: { type: boolean, description: 'true when older than 60s (persisted rates are always stale)' }
                      source: { type: string, enum: [ptax, wdo, dol, persisted] }

  /api/v1/market/hl/outcomes:
    get:
      tags: [Market data]
      summary: HL HIP-4 outcome catalog
      security:
        - BearerAuth: []
      responses:
        '200': { description: 'Outcome events list. Each outcome carries `enabled: boolean` — true iff the outcome id is registered in the caller-tenant operator registry (the same allowlist order composition enforces via hip4_market_disabled), so clients can gate tradeable tiles without probing for the 403.' }

  # ============================================================
  # REPORTS
  # ============================================================

  /api/v1/reports/orders:
    get:
      tags: [Reports]
      summary: Historical orders
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdQuery'
        - $ref: '#/components/parameters/CursorLimit'
        - $ref: '#/components/parameters/DateRange'
      responses:
        '200':
          description: Order history
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderListResponse'

  /api/v1/reports/fills:
    get:
      tags: [Reports]
      summary: Historical fills
      description: >-
        Reconciliation surface. Rows with `venue` 'paper' were matched by our
        own engine, not by an exchange, and will not appear on any exchange
        statement — that covers the sandbox and internalised (B-booked)
        real-money accounts alike. See "Execution model" in the API
        description.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdQuery'
        - $ref: '#/components/parameters/CursorLimit'
      responses:
        '200': { description: Fill history }

  /api/v1/reports/positions/closed:
    get:
      tags: [Reports]
      summary: Closed positions (round-trip trades)
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdQuery'
        - $ref: '#/components/parameters/CursorLimit'
      responses:
        '200': { description: Closed positions }

  /api/v1/reports/daily-pnl:
    get:
      tags: [Reports]
      summary: Per-day realized PnL rollup
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdQuery'
        - { in: query, name: limit, schema: { type: integer, default: 90, maximum: 365 } }
      responses:
        '200':
          description: Daily PnL series
          content:
            application/json:
              schema:
                type: object
                properties:
                  dailyPnl:
                    type: array
                    items:
                      type: object
                      properties:
                        day: { type: string, format: date }
                        pnl: { type: string, description: 'Decimal as string' }
                        tradeCount: { type: integer }

  /api/v1/reports/funding:
    get:
      tags: [Reports]
      summary: Funding / deposits / withdrawals
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdQuery'
        - $ref: '#/components/parameters/CursorLimit'
      responses:
        '200': { description: Funding events }

  /api/v1/reports/statements:
    get:
      tags: [Reports]
      summary: Monthly statement summaries
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AccountIdQuery'
        - { in: query, name: limit, schema: { type: integer, default: 12, maximum: 60 } }
      responses:
        '200':
          description: Monthly statements
          content:
            application/json:
              schema:
                type: object
                properties:
                  statements:
                    type: array
                    items:
                      type: object
                      properties:
                        month: { type: string, example: '2026-05' }
                        pnl: { type: string }
                        tradeCount: { type: integer }
                        grossNotional: { type: string }

  # ============================================================
  # SOR (Smart Order Routing — user-facing audit + savings)
  # ============================================================

  /api/v1/sor/decisions:
    get:
      tags: [SOR]
      summary: List the account's SOR decisions
      description: |
        Every order routed via Smart Order Routing emits a signed
        decision row (`route_decisions`). This endpoint returns the
        caller's account decisions in reverse-chronological order
        with cursor pagination. The `candidates` blob captures every
        venue evaluated + skip reasons — surface in the FE's
        best-execution dashboard.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - in: query
          name: outcome
          schema:
            type: string
            enum: [ok, no_eligible_venue, no_quotes, asset_unknown]
        - { in: query, name: limit, schema: { type: integer, default: 100, maximum: 500 } }
        - { in: query, name: cursor, schema: { type: string } }
      responses:
        '200':
          description: Decision page
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        asset: { type: string, example: BTC }
                        side: { type: string, enum: [buy, sell] }
                        qty: { type: string }
                        outcome: { type: string }
                        chosenVenue: { type: string, nullable: true }
                        chosenSymbol: { type: string, nullable: true }
                        expectedFillPrice: { type: string, nullable: true }
                        totalCostBps: { type: string, nullable: true }
                        candidates:
                          type: array
                          items:
                            type: object
                        intentId: { type: string, nullable: true }
                        emittedAt: { type: string, format: date-time }
                  nextCursor: { type: string, nullable: true }
                  hasMore: { type: boolean }

  /api/v1/sor/decisions/summary:
    get:
      tags: [SOR]
      summary: Aggregate cost-savings over a window
      description: |
        Sums (worst-candidate.totalCostBps − chosen.totalCostBps) ×
        notional across every OK decision in the window. Defaults to
        last 30 days. Powers the "you saved $X this month" hero
        metric on the best-execution dashboard.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - { in: query, name: from, schema: { type: string, format: date-time } }
        - { in: query, name: to,   schema: { type: string, format: date-time } }
      responses:
        '200':
          description: Summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  window:
                    type: object
                    properties:
                      from: { type: string, format: date-time }
                      to: { type: string, format: date-time }
                  totalDecisions: { type: integer }
                  okDecisions: { type: integer }
                  skippedDecisions: { type: integer }
                  totalNotionalUsd: { type: string }
                  totalCostSavedUsd: { type: string }
                  perVenueOkCount:
                    type: object
                    additionalProperties: { type: integer }

  # ============================================================
  # VENUES (capability discovery)
  # ============================================================

  /api/v1/venues:
    get:
      tags: [Venues]
      summary: List registered venue adapters + capabilities
      description: |
        Returns every venue the BE has registered (hyperliquid,
        aster, polymarket, b3, …) with each adapter's asset
        classes, supported order types, and signing posture. SDK
        consumers + admin UI read this to enumerate what's wired
        without hardcoding.
      security:
        - BearerAuth: []
        - CookieAuth: []
      responses:
        '200':
          description: Venue list
          content:
            application/json:
              schema:
                type: object
                properties:
                  venues:
                    type: array
                    items:
                      type: object
                      properties:
                        slug: { type: string, example: hyperliquid }
                        displayName: { type: string }
                        capabilities:
                          type: object
                          properties:
                            assetClasses:
                              type: array
                              items: { type: string }
                            orderTypes:
                              type: array
                              items: { type: string }
                            reduceOnly: { type: boolean }
                            postOnly: { type: boolean }
                            brackets: { type: boolean }
                            onChainSigning: { type: boolean }

  # ============================================================
  # KYC (user verifications)
  # ============================================================

  /api/v1/kyc:
    get:
      tags: [KYC]
      summary: List the user's KYC verifications
      security: [{ BearerAuth: [] }, { CookieAuth: [] }]
      responses:
        '200':
          description: List
          content:
            application/json:
              schema:
                type: object
                properties:
                  verifications:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        provider: { type: string, enum: [sumsub, onfido, persona, manual, stub] }
                        providerRef: { type: string, nullable: true }
                        tier: { type: integer, enum: [1, 2, 3] }
                        status: { type: string, enum: [pending, in_review, approved, rejected, expired] }
                        statusReason: { type: string, nullable: true }
                        walletAddress: { type: string, nullable: true }
                        createdAt: { type: string, format: date-time }
                        updatedAt: { type: string, format: date-time }
                        expiresAt: { type: string, format: date-time, nullable: true }

  /api/v1/kyc/initiate:
    post:
      tags: [KYC]
      summary: Start a KYC verification flow
      description: |
        Returns an accessUrl that the FE opens (Sumsub hosted page,
        Onfido/Persona iframe URL). Upserts a kyc_verifications row
        keyed on (userId, provider). Subsequent calls with the same
        provider replace the providerRef and reset status to pending.
      security: [{ BearerAuth: [] }, { CookieAuth: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                tier: { type: integer, enum: [1, 2, 3], default: 1 }
                provider: { type: string, enum: [sumsub, onfido, persona, stub] }
                locale: { type: string, example: en-US }
                countryCode: { type: string, example: BR, description: 'ISO 3166-1 alpha-2' }
      responses:
        '201':
          description: Verification initiated
          content:
            application/json:
              schema:
                type: object
                properties:
                  verificationId: { type: string }
                  providerRef: { type: string }
                  accessUrl: { type: string, format: uri }
                  accessExpiresAt: { type: string, format: date-time, nullable: true }

  /api/v1/kyc/webhook/{provider}:
    post:
      tags: [KYC]
      summary: Vendor webhook callback
      description: |
        Public endpoint receiving the KYC vendor's status callbacks.
        Each provider parses + verifies its own signature header
        (Sumsub: x-payload-digest; Onfido: X-SHA2-Signature; Persona:
        signature). On success, updates the kyc_verifications row +
        emits a kyc_events audit row.
      parameters:
        - in: path
          name: provider
          required: true
          schema: { type: string, enum: [sumsub, onfido, persona, stub] }
      requestBody:
        description: Vendor-shaped payload — parsed by the per-provider adapter.
        content:
          application/json:
            schema: { type: object }
      responses:
        '200': { description: Status updated }
        '400': { description: Invalid signature or payload }
        '404': { description: Unknown provider or providerRef }

  # ============================================================
  # REFERRALS (Track B W11-12)
  # ============================================================

  /api/v1/referrals:
    get:
      tags: [Referrals]
      summary: Caller's referral code + claim ledger
      description: |
        Mints the caller's code on first call (idempotent). Returns
        the code + bps share + per-claim audit list of accounts the
        caller referred + whether the caller themselves claimed
        someone else's code.
      security: [{ BearerAuth: [] }, { CookieAuth: [] }]
      responses:
        '200':
          description: Referral state
          content:
            application/json:
              schema:
                type: object
                properties:
                  code: { type: string, example: a7k3m9 }
                  bpsShare: { type: integer, example: 5 }
                  referredCount: { type: integer }
                  claimedFrom:
                    type: object
                    nullable: true
                    properties:
                      referrerAccountId: { type: string }
                      code: { type: string }
                      bpsShare: { type: integer }
                      claimedAt: { type: string, format: date-time }
                  claims:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        refereeAccountId: { type: string }
                        code: { type: string }
                        bpsShare: { type: integer }
                        claimedAt: { type: string, format: date-time }

  /api/v1/referrals/info:
    get:
      tags: [Referrals]
      summary: Public lookup of a referral code (pre-signup)
      description: |
        Resolve a code to its referrer + bps share before the user
        finishes signing up. No auth.
      parameters:
        - in: query
          name: code
          required: true
          schema: { type: string, minLength: 4, maxLength: 16 }
      responses:
        '200':
          description: Code resolved
          content:
            application/json:
              schema:
                type: object
                properties:
                  code: { type: string }
                  referrerAccountId: { type: string }
                  tenantId: { type: string }
                  bpsShare: { type: integer }
                  claimCount: { type: integer }
        '404':
          description: Unknown code

  /api/v1/referrals/claim:
    post:
      tags: [Referrals]
      summary: Apply a referral code to the caller's account
      description: |
        First claim wins. Self-referral + cross-tenant codes are
        rejected.
      security: [{ BearerAuth: [] }, { CookieAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
      responses:
        '201': { description: Claim recorded }
        '400': { description: self_referral }
        '403': { description: cross_tenant_code }
        '404': { description: unknown_code }
        '409': { description: already_claimed }

  # ============================================================
  # TENANTS (caller's own tenant config — SS-1.3 reads + C1 writes)
  # ============================================================

  /api/v1/tenants/{id}:
    get:
      tags: [Tenants]
      summary: Read the caller's tenant config (aggregate)
      description: |
        `{id}` accepts the literal tenant slug OR `me`; either way it MUST
        resolve to the caller's OWN tenant (cross-tenant reads 403). Returns
        the tenant identity plus every config surface (branding / theme /
        flags / venues / plan) in one round-trip — for Mode-B partners
        rendering their own FE on our engine.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - in: path
          name: id
          required: true
          description: Tenant slug, or `me` for the caller's own tenant.
          schema: { type: string }
      responses:
        '200':
          description: Tenant config aggregate
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenantConfigAggregate' }
        '401':
          description: no_tenant_session
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: cross_tenant_forbidden
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/tenants/{id}/branding:
    get:
      tags: [Tenants]
      summary: Read the caller's tenant branding
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Branding config
          content:
            application/json:
              schema:
                type: object
                properties:
                  branding: { $ref: '#/components/schemas/TenantBrandingConfig' }
        '401':
          description: no_tenant_session
        '403':
          description: cross_tenant_forbidden
    patch:
      tags: [Tenants]
      summary: Write the caller's tenant branding
      description: |
        Requires an API client credential carrying the **`tenant:config`**
        scope (403 `tenant_config_required` for cookie/user sessions or a
        credential without the scope). Hard-scoped to the credential's own
        tenant. Body is validated against the exact same schema the admin
        console PUT /admin-api/branding uses, so the two write surfaces can't
        drift. Every write is audited (`tenant_config.branding_update`).
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TenantBrandingConfig' }
      responses:
        '200':
          description: Branding updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  branding: { $ref: '#/components/schemas/TenantBrandingConfig' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: unauthorized
        '403':
          description: tenant_config_required / tenant_unresolved / cross_tenant_forbidden

  /api/v1/tenants/{id}/theme:
    get:
      tags: [Tenants]
      summary: Read the caller's tenant theme
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Theme config
          content:
            application/json:
              schema:
                type: object
                properties:
                  theme: { $ref: '#/components/schemas/TenantThemeConfig' }
        '401':
          description: no_tenant_session
        '403':
          description: cross_tenant_forbidden
    patch:
      tags: [Tenants]
      summary: Write the caller's tenant theme
      description: |
        Requires the **`tenant:config`** scope (see `PATCH .../branding`).
        Body validated against the same schema as the admin console's
        PUT /admin-api/theme (14-token legacy base + Tier-1B typography/radius
        + Phase-2 tweakcn preset/overrides/HSL-shift/shadow-studio fields —
        every field optional, unset fields keep their prior value). Audited
        as `tenant_config.theme_update`.
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TenantThemeConfig' }
      responses:
        '200':
          description: Theme updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  theme: { $ref: '#/components/schemas/TenantThemeConfig' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: unauthorized
        '403':
          description: tenant_config_required / tenant_unresolved / cross_tenant_forbidden

  /api/v1/tenants/{id}/flags:
    get:
      tags: [Tenants]
      summary: Read the caller's tenant feature-flag overrides
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Feature flag overrides
          content:
            application/json:
              schema:
                type: object
                properties:
                  flags: { $ref: '#/components/schemas/TenantFeatureFlags' }
        '401':
          description: no_tenant_session
        '403':
          description: cross_tenant_forbidden
    patch:
      tags: [Tenants]
      summary: Write the caller's tenant feature-flag overrides
      description: |
        Requires the **`tenant:config`** scope (see `PATCH .../branding`).
        Body is a flat `{ [flagKey]: boolean }` map — same schema as the
        admin console's PUT /admin-api/feature-flags. **SECURITY:** a
        false→true transition on a regulated / money-moving compliance flag
        is always rejected (403 `compliance_flag_forbidden`) — no API
        credential, however scoped, may self-enable those; a no-op re-save of
        an already-on flag still passes. Audited as
        `tenant_config.flags_update` (or `tenant_config.compliance_enable_denied`
        on the 403).
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TenantFeatureFlags' }
      responses:
        '200':
          description: Flags updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  flags: { $ref: '#/components/schemas/TenantFeatureFlags' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: unauthorized
        '403':
          description: >-
            tenant_config_required / tenant_unresolved / cross_tenant_forbidden
            / compliance_flag_forbidden

  /api/v1/tenants/{id}/venues:
    get:
      tags: [Tenants]
      summary: Read the caller's tenant's enabled trading venues
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Enabled venues
          content:
            application/json:
              schema:
                type: object
                properties:
                  venues: { $ref: '#/components/schemas/TenantVenues' }
        '401':
          description: no_tenant_session
        '403':
          description: cross_tenant_forbidden

  /api/v1/tenants/{id}/plan:
    get:
      tags: [Tenants]
      summary: Read the caller's tenant's effective billing plan
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Effective plan + entitlements
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan: { $ref: '#/components/schemas/TenantPlan' }
        '401':
          description: no_tenant_session
        '403':
          description: cross_tenant_forbidden

  # ============================================================
  # EARN
  # ============================================================

  /api/v1/earn/vaults:
    get:
      tags: [Earn]
      summary: Aave + Morpho vault catalog
      security: [{ BearerAuth: [] }]
      responses:
        '200': { description: Vaults }

  /api/v1/earn/positions:
    get:
      tags: [Earn]
      summary: Active earn positions
      security: [{ BearerAuth: [] }]
      responses:
        '200': { description: Positions }

  /api/v1/earn/deposit/calldata:
    post:
      tags: [Earn]
      summary: Build approve + supply calldata
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [vault, amount]
              properties:
                vault: { type: string }
                amount: { type: string }
      responses:
        '200': { description: Calldata }

  /api/v1/earn/withdraw/calldata:
    post:
      tags: [Earn]
      summary: Build Aave / Morpho withdraw calldata
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [vault, amount]
              properties:
                vault: { type: string }
                amount: { type: string }
      responses:
        '200': { description: Calldata }

  # ============================================================
  # SYMBOLS
  # ============================================================

  /api/v1/symbols/{venue}:
    get:
      tags: [Symbols]
      summary: Discovered symbol catalog per venue
      description: |
        Per-venue symbol catalog. FUTURES symbols (venue `massive`, `.FUT`
        suffix) additionally carry `marketStatus: 'open' | 'closed'` (omitted =
        treat as open) reflecting the CME/CBOT/NYMEX/COMEX session state, and an
        optional `reopenAt` ISO timestamp. A closed futures market stays in the
        catalog (it is not dropped) so clients can render it with a closed badge;
        placing an order into a closed market is rejected with `market_closed`.
        B3 futures carry `contractMultiplier` — the BRL point value per contract
        (WIN 0.20 / IND 1 / WDO 10 / DOL 50); omitted or 1 elsewhere.
      security: [{ BearerAuth: [] }]
      parameters:
        - { in: path, name: venue, required: true, schema: { $ref: '#/components/schemas/Venue' } }
      responses:
        '200': { description: 'Symbol list (futures rows include marketStatus / reopenAt)' }

  # ============================================================
  # META
  # ============================================================

  /api/v1/discovery:
    get:
      tags: [Meta]
      summary: Provider API surface catalog
      description: |
        Public — reads no session. Plain JSON describing the public REST
        surface (bearer-mint URLs, every documented `/api/v1/*` endpoint with
        its required tier, the two WS streams, and free-text usage notes).
        Lightweight alternative to parsing this OpenAPI doc; integrators hit
        it to learn what they can call in one round-trip.
      responses:
        '200':
          description: Discovery document
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DiscoveryDocument' }

  # ============================================================
  # BRANDING / PUBLIC
  # ============================================================

  /api/branding:
    get:
      tags: [Branding]
      summary: Tenant-scoped branding assets
      description: Public — no auth. Tenant resolved by Origin / X-Tenant-Slug.
      parameters:
        - $ref: '#/components/parameters/TenantSlug'
      responses:
        '200':
          description: Branding config
          content:
            application/json:
              schema:
                type: object
                properties:
                  config:
                    type: object
                    nullable: true
                    properties:
                      brandName: { type: string }
                      logoUrl: { type: string }
                      logoDarkUrl: { type: string }
                      faviconUrl: { type: string }

  /api/theme:
    get:
      tags: [Branding]
      summary: Tenant-scoped theme overrides + custom theme catalog
      parameters:
        - $ref: '#/components/parameters/TenantSlug'
      responses:
        '200': { description: Theme config }

  /api/features:
    get:
      tags: [Branding]
      summary: Tenant-scoped feature flags
      parameters:
        - $ref: '#/components/parameters/TenantSlug'
      responses:
        '200':
          description: Feature flag map
          content:
            application/json:
              schema:
                type: object
                properties:
                  flags:
                    type: object
                    additionalProperties: { type: boolean }

  # ============================================================
  # MCP
  # ============================================================

  /api/v1/llm/copilot:
    post:
      tags: [MCP]
      summary: SSE proxy to Anthropic for AI co-pilot chat
      security: [{ BearerAuth: [] }]
      responses:
        '200':
          description: SSE stream
          content:
            text/event-stream: {}

  # ============================================================
  # ANALYTICS — pre-digested trade-journal aggregates. Each scopes
  # to the caller's own account (session.accountId); no user-supplied
  # account id. `window` is 1d|7d|30d|90d|all (default 30d).
  # ============================================================

  /api/v1/analytics/performance:
    get:
      tags: [Analytics]
      summary: Performance rollup (P&L, win rate, volume, streak)
      description: Single-shot aggregate over the window — counts, gross/net P&L, fees, per-venue + per-side volume, current win/loss streak.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AnalyticsWindow'
      responses:
        '200':
          description: Performance aggregate
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnalyticsPerformance' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/analytics/per-symbol:
    get:
      tags: [Analytics]
      summary: Per-symbol breakdown
      description: One row per symbol traded in the window, sorted by abs(pnl) descending.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AnalyticsWindow'
      responses:
        '200':
          description: Per-symbol rows
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnalyticsPerSymbol' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/analytics/prop-diagnosis/{id}:
    get:
      tags: [Analytics]
      summary: Prop-account status + recommendation
      description: Status, template context, last 50 in-lifetime trades, and a recommendation keyword for one prop account owned by the caller.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
          description: Prop account id
      responses:
        '200':
          description: Diagnosis
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PropDiagnosis' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/analytics/equity-curve:
    get:
      tags: [Analytics]
      summary: Downsampled total-equity time series
      description: Step-summed total account equity across venues, downsampled to `points` (2–1000, default 200). `capped` is true when the source hit the 20k-row bound.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AnalyticsWindow'
        - in: query
          name: points
          schema: { type: integer, default: 200, minimum: 2, maximum: 1000 }
      responses:
        '200':
          description: Equity curve
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnalyticsEquityCurve' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/analytics/risk-metrics:
    get:
      tags: [Analytics]
      summary: Summary risk metrics (return, drawdown, vol, Sharpe/Sortino/Calmar)
      description: Computed over the full (un-downsampled) equity series. Metrics are gross approximations of deployed equity (see notes on the equity-curve endpoint).
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - $ref: '#/components/parameters/AnalyticsWindow'
      responses:
        '200':
          description: Risk metrics
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AnalyticsRiskMetrics' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  # ============================================================
  # INDICATORS — CRUD over saved indicator presets (account-scoped).
  # Lab-flag gated: every route 503s when `lab.indicators` is OFF.
  # ============================================================

  /api/v1/indicators:
    get:
      tags: [Indicators]
      summary: List saved indicators
      security:
        - BearerAuth: []
        - CookieAuth: []
      responses:
        '200':
          description: Indicator list
          content:
            application/json:
              schema:
                type: object
                properties:
                  indicators:
                    type: array
                    items: { $ref: '#/components/schemas/Indicator' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '503': { $ref: '#/components/responses/FeatureDisabled' }
    post:
      tags: [Indicators]
      summary: Register an indicator preset
      security:
        - BearerAuth: []
        - CookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateIndicatorRequest' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  indicator: { $ref: '#/components/schemas/Indicator' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409':
          description: An indicator with that name already exists for the account
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503': { $ref: '#/components/responses/FeatureDisabled' }

  /api/v1/indicators/_meta/templates:
    get:
      tags: [Indicators]
      summary: Catalog of supported templates + param shapes
      security:
        - BearerAuth: []
        - CookieAuth: []
      responses:
        '200':
          description: Template catalog
          content:
            application/json:
              schema:
                type: object
                properties:
                  templates:
                    type: array
                    items: { $ref: '#/components/schemas/IndicatorTemplateMeta' }
        '503': { $ref: '#/components/responses/FeatureDisabled' }

  /api/v1/indicators/{id}:
    get:
      tags: [Indicators]
      summary: Fetch one saved indicator
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        '200':
          description: Indicator
          content:
            application/json:
              schema:
                type: object
                properties:
                  indicator: { $ref: '#/components/schemas/Indicator' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/FeatureDisabled' }
    patch:
      tags: [Indicators]
      summary: Update an indicator (name and/or params)
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateIndicatorRequest' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  indicator: { $ref: '#/components/schemas/Indicator' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/FeatureDisabled' }
    delete:
      tags: [Indicators]
      summary: Delete an indicator
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  deletedId: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/FeatureDisabled' }

  # ============================================================
  # BACKTESTS — declarative no-RCE strategy backtester. Account +
  # tenant scoped; runs are persisted (newest 100 per account kept).
  # ============================================================

  /api/v1/backtests/run:
    post:
      tags: [Backtests]
      summary: Run a declarative backtest synchronously
      description: Pulls historical candles (HL / Aster REST) and runs the deterministic engine. Returns summary + equity curve + fills + signals inline and persists the run (best-effort — `id` is null if persistence failed).
      security:
        - BearerAuth: []
        - CookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BacktestRunRequest' }
      responses:
        '200':
          description: Backtest result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BacktestResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: No historical candles for that symbol / interval / window
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          description: Candle fetch from the venue failed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/backtests:
    get:
      tags: [Backtests]
      summary: List recent backtest runs (light columns)
      description: Newest first. Omits equity curve / fills / signals so the payload stays small.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - { in: query, name: limit, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Run list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BacktestListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/backtests/{id}:
    get:
      tags: [Backtests]
      summary: Fetch a stored backtest run in full
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        '200':
          description: Full run (spec + curve + fills + signals)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BacktestRunDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ============================================================
  # COPY TRADING — self + social copy. Subscription CRUD + leader
  # consent profile + public discovery board + perf-fee ledgers.
  # Under /api/* (not /api/v1/*). Account-rooted + tenant-scoped.
  # ============================================================

  /api/copy-subscriptions:
    get:
      tags: [Copy trading]
      summary: List my copy subscriptions (as follower + as leader)
      security:
        - BearerAuth: []
        - CookieAuth: []
      responses:
        '200':
          description: Subscriptions across every account the caller owns
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CopySubscriptionsList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Copy trading]
      summary: Open a copy subscription
      description: >-
        Copy INTO one of your accounts FROM a leader. 'self' = both accounts
        yours (needs `leaderAccountId`); 'social' = another user's leader,
        resolved by `leaderAccountId` or `leaderAccountNumber` and requiring
        the `copyTradingSocial` flag + the leader's public opt-in. Writes are
        gated behind the `copyTrading` feature flag.
      security:
        - BearerAuth: []
        - CookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateCopySubscriptionRequest' }
      responses:
        '201':
          description: Subscription created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CopySubscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: >-
            copy trading disabled, social copy not enabled, follower/leader not
            owned or wrong tenant, or leader has not consented (not public / at
            capacity / below min balance)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Leader account not found in your tenant
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/copy-subscriptions/{id}:
    patch:
      tags: [Copy trading]
      summary: Pause / resume / re-term a subscription
      description: The caller must own the subscription's follower account. Gated behind `copyTrading`.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateCopySubscriptionRequest' }
      responses:
        '200':
          description: Updated subscription
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CopySubscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Copy trading]
      summary: Cancel a subscription (terminal)
      description: Stops the copy fan-out. The caller must own the follower account. Gated behind `copyTrading`.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
      responses:
        '200':
          description: Cancelled subscription
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CopySubscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/leader-profile:
    get:
      tags: [Copy trading]
      summary: My active account's leader profile (or null)
      security:
        - BearerAuth: []
        - CookieAuth: []
      responses:
        '200':
          description: The profile, or null when never opted in
          content:
            application/json:
              schema:
                oneOf:
                  - { $ref: '#/components/schemas/LeaderProfile' }
                  - { type: 'null' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    put:
      tags: [Copy trading]
      summary: Upsert my leader profile (the consent action)
      description: >-
        `isPublic:true` makes my active account copyable by strangers — the
        cross-user consent gate. Also sets discovery metadata + optional
        follower gates (min balance / max followers). Not gated behind
        `copyTrading` so a leader can always withdraw consent.
      security:
        - BearerAuth: []
        - CookieAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpsertLeaderProfileRequest' }
      responses:
        '200':
          description: The upserted profile
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeaderProfile' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    delete:
      tags: [Copy trading]
      summary: Un-publish my leader profile
      description: Sets `isPublic → false`; the account immediately stops being copyable by new followers. Keeps the row for one-PUT re-publishing.
      security:
        - BearerAuth: []
        - CookieAuth: []
      responses:
        '200':
          description: The un-published profile
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeaderProfile' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/leaderboard:
    get:
      tags: [Copy trading]
      summary: Ranked public (consented) leaders for a window
      description: One row per opted-in public leader with their trader stats. Tenant-scoped; only accounts with a public leader profile appear.
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - { in: query, name: window, schema: { type: string, enum: ['7d', '30d', all], default: '30d' } }
        - { in: query, name: orderBy, schema: { type: string, enum: [pnl, winRate, drawdown], default: pnl } }
        - { in: query, name: limit, schema: { type: integer, default: 25, maximum: 100 } }
      responses:
        '200':
          description: Leaderboard
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LeaderboardResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/traders/{accountNumber}:
    get:
      tags: [Copy trading]
      summary: One consented leader's public profile
      description: >-
        Profile + every window's latest trader stats + active-follower count,
        resolved by human-readable account number. 404 unless the account has a
        public leader profile in your tenant (private/absent are indistinguishable).
      security:
        - BearerAuth: []
        - CookieAuth: []
      parameters:
        - { in: path, name: accountNumber, required: true, schema: { type: string } }
      responses:
        '200':
          description: Trader profile
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TraderProfile' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/copy-earnings:
    get:
      tags: [Copy trading]
      summary: My perf-fee earnings as a leader
      description: Perf-fee accruals the caller has earned across their followers — totals partitioned by status + a per-follower breakdown. Read-only ledger.
      security:
        - BearerAuth: []
        - CookieAuth: []
      responses:
        '200':
          description: Earnings ledger
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CopyEarnings' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/copy-fees:
    get:
      tags: [Copy trading]
      summary: My owed / paid perf fees as a follower
      description: Perf fees the caller owes (and has paid) on their subscriptions — owed / paid totals + per-subscription rows with the leader counterparty. Read-only ledger.
      security:
        - BearerAuth: []
        - CookieAuth: []
      responses:
        '200':
          description: Fees ledger
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CopyFees' }
        '401': { $ref: '#/components/responses/Unauthorized' }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: 24h JWT from `POST /api/auth/api-token`
    CookieAuth:
      type: apiKey
      in: cookie
      name: tron_session
      description: SIWE or social-signin cookie (browser FE only)

  parameters:
    TenantSlug:
      in: header
      name: X-Tenant-Slug
      description: Authoritative tenant binding. Falls back to Origin / Host resolution if absent.
      required: false
      schema:
        type: string
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      description: 8–200 char client-supplied key. Replay-safe within 24h.
      required: false
      schema:
        type: string
    AccountIdPath:
      in: path
      name: accountId
      required: true
      schema: { type: string }
    PropAccountIdPath:
      in: path
      name: propAccountId
      required: true
      schema: { type: string }
    AccountIdQuery:
      in: query
      name: accountId
      required: true
      schema: { type: string }
    CursorLimit:
      in: query
      name: limit
      required: false
      schema: { type: integer, default: 100, maximum: 500 }
      description: Pair with `cursor` from previous response.
    DateRange:
      in: query
      name: from
      schema: { type: string, format: date-time }
    AnalyticsWindow:
      in: query
      name: window
      required: false
      description: Rolling window. Unknown values fall back to 30d.
      schema:
        type: string
        enum: [1d, 7d, 30d, 90d, all]
        default: 30d

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing or invalid credentials
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: |
        Authenticated but lacks the required tier / role / scope.

        Check `error` before assuming a missing grant. `wrong_credential_audience`
        means the wrong KIND of credential — a trader credential on the operator
        surface, or an operator credential on the trading surface — and no scope
        or tier change fixes it; audiences are immutable, so mint the other
        credential. See the WrongCredentialAudience response.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found (or not owned by the caller)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    WrongCredentialAudience:
      description: >-
        The wrong KIND of credential. A credential is minted as either `trader`
        (acts on its own account, holds an agent wallet, can place orders) or
        `operator` (administers a tenant, holds scopes, can never place orders),
        and the audience is immutable. Mint the other one and keep both — an
        operator credential cannot be upgraded into a trader one.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, enum: [wrong_credential_audience] }
              detail: { type: string, description: 'Names both audiences and where to mint the one this endpoint wants.' }
              expected: { type: string, enum: [trader, operator] }
              actual: { type: string, enum: [trader, operator] }
    ManagedByTemplate:
      description: >-
        This risk profile is managed by a prop template and is read-only on this
        surface. Edit the template's `rules` instead.
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, enum: [managed_by_template] }
              detail: { type: string }
              templateId:
                type: string
                description: The prop template that owns this profile.
    RateLimited:
      description: Too many requests
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds to wait before retrying
    IdempotencyConflict:
      description: Idempotency-Key was previously used with a different request body
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    VenueRejected:
      description: Venue rejected the order (insufficient margin, symbol not enabled, etc.)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    FeatureDisabled:
      description: The feature is disabled by the operator (lab flag OFF)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    AccountSummary:
      type: object
      description: A trading account with its bound wallets.
      properties:
        id: { type: string, format: uuid }
        accountNumber: { type: string, example: '001234' }
        kind: { type: string, enum: [live, paper] }
        capitalModel:
          type: string
          enum: [paper, live, live_prop]
          description: >-
            Canonical capital class. 'paper' = simulated capital (includes
            paper-prop). 'live' = personal live (DEX-agg) capital. 'live_prop' =
            a live account backing a prop challenge; the capital is
            firm-custodied and segregated from the personal deposit/withdraw
            rails (those rails are hard-denied for live_prop accounts).
        displayName: { type: string, nullable: true }
        smartRoutingEnabled: { type: boolean }
        preferredVenue: { type: string, nullable: true, enum: [hyperliquid, aster] }
        baseCurrency:
          type: string
          nullable: true
          enum: [USD, BRL]
          description: >-
            Account DENOMINATION. Every ledger amount on the account
            (balances, equity, trade pnl/fees) is denominated in this
            currency; trades settling in a different currency (B3 = BRL)
            convert at the platform USDBRL rate (realized at fill time,
            unrealized at read). null = USD. Fixed once the account has
            trading history.
        suspendedAt: { type: string, format: date-time, nullable: true }
        wallets:
          type: array
          items:
            type: object
            properties:
              walletAddress: { type: string }
      additionalProperties: true
    CreateAccountRequest:
      type: object
      required: [kind]
      properties:
        kind: { type: string, enum: [live, paper] }
        displayName:
          type: string
          minLength: 1
          maxLength: 64
          description: >-
            Optional. Omit it and the server names the account after its kind
            and denomination (`Paper BRL`, `Live USD`), appending an ordinal
            only from the second account of that pair onward (`Paper BRL 2`).
        baseCurrency:
          type: string
          enum: [USD, BRL]
          description: >-
            Account denomination. Creation is the only unrestricted moment to
            set it — PATCH refuses the change once the account has trading
            history, since it would re-denominate every persisted amount.
            Defaults to USD.
        baselineUsd:
          type: string
          pattern: '^\d+(\.\d+)?$'
          description: Paper-only starting balance override (capped server-side).
        linkPrimaryWallet:
          type: boolean
          default: false
          description: Live-only — bind the session wallet 1:1 to the new account.
        tradingGroupId:
          type: string
          description: >-
            Trading group to allocate the account to — its venue/symbol scope,
            fee schedule and leverage caps. Must be a group in your tenant
            (400 `trading_group_not_found`). Omit to inherit the tenant's
            default group. Setting it here avoids the create-then-bind window
            where the account is live on the wrong scope.
        riskProfileId:
          type: string
          description: >-
            Risk profile to bind at creation, saving a round trip to
            `/risk-profiles/{id}/bind`. Must be an AUTHORED profile in your
            tenant; a managed profile belongs to its prop template and is
            rejected with 400 `profile_is_managed`. Omit for no profile
            (unenforced — the default).
    PatchAccountRequest:
      type: object
      minProperties: 1
      properties:
        displayName: { type: string, minLength: 1, maxLength: 64 }
        smartRoutingEnabled: { type: boolean }
        preferredVenue: { type: string, nullable: true, enum: [hyperliquid, aster] }
        baseCurrency:
          type: string
          nullable: true
          enum: [USD, BRL]
          description: >-
            Account DENOMINATION (see AccountSummary.baseCurrency). null
            clears to USD. Rejected with 409 `base_currency_locked` once the
            account has any trading history — the denomination is fixed after
            the first trade.
    TraderUser:
      type: object
      description: >-
        A trader identity — the person an account belongs to. Its `id` is the
        `ownerUserId` used when provisioning accounts.
      properties:
        id: { type: string, description: 'Use this as `ownerUserId`.' }
        tenantId: { type: string }
        email: { type: string, nullable: true }
        displayName: { type: string, nullable: true }
        status: { type: string, description: 'Identity lifecycle standing. Not an order-time gate — block the account for that.' }
        externalId: { type: string, nullable: true, description: 'Your own id for this trader.' }
        tags: { type: array, items: { type: string } }
        createdAt: { type: string, format: date-time }
        lastSeenAt: { type: string, format: date-time }
    TraderUserCreate:
      type: object
      description: All fields optional — a bare `{}` mints an anonymous trader identity.
      properties:
        email: { type: string, format: email, description: 'Unique per tenant; 409 on a clash. Not an auth factor.' }
        displayName: { type: string, minLength: 1, maxLength: 120 }
        externalId:
          type: string
          minLength: 1
          maxLength: 120
          description: >-
            Your own id for this trader. Makes the create idempotent — sending
            the same one again returns the existing trader with HTTP 200 and
            `idempotent: true`.
        tags: { type: array, maxItems: 20, items: { type: string, maxLength: 40 } }
    TraderUserPatch:
      type: object
      minProperties: 1
      properties:
        displayName: { type: string, minLength: 1, maxLength: 120 }
        status: { type: string, maxLength: 40 }
        tags: { type: array, maxItems: 20, items: { type: string, maxLength: 40 } }
    CreatePropRequest:
      type: object
      required: [templateId, propTenantId, mode]
      properties:
        templateId: { type: string }
        propTenantId: { type: string, format: uuid }
        mode: { type: string, enum: [paper, live] }
        displayName: { type: string, minLength: 1, maxLength: 64 }
        baselineUsd:
          type: string
          pattern: '^\d+(\.\d+)?$'
          description: Paper-only baseline override; ignored for live mode.
        ownerUserId:
          type: string
          maxLength: 80
          description: |
            API-client provisioning only: the user (in your tenant) to own the
            new account. Required for API-client callers (requires the
            `prop:manage` scope); ignored for user sessions.
    SandboxPaperAccountRequest:
      type: object
      description: All fields optional; sensible defaults applied server-side.
      properties:
        baselineUsd:
          type: string
          pattern: '^\d+(\.\d+)?$'
          description: Paper starting balance. Default "50000".
        symbol:
          type: string
          minLength: 1
          maxLength: 32
          description: >
            Demo-position symbol — needs a venue suffix to be priceable
            (BTC.HL / BTC.LX / ES.FUT / …A / …K) so its mark tracks the live
            feed. Default "BTC.HL".
        seedDemoPosition:
          type: boolean
          description: Seed one demo position so Margin/Risk frames have data. Default true.
        displayName: { type: string, minLength: 1, maxLength: 64 }
    SandboxPaperAccountResult:
      type: object
      required: [accountId, accountNumber, propTenantId, templateId, mode, demoPositionSeeded, ws]
      properties:
        accountId: { type: string, format: uuid }
        accountNumber: { type: string, description: 'Canonical account number (bare zero-padded digits; a legacy PH-/TC- prefix is still accepted on input).' }
        propTenantId: { type: string, format: uuid }
        templateId: { type: string }
        mode: { type: string, enum: [paper] }
        demoPositionSeeded: { type: boolean }
        ws:
          type: object
          properties:
            hint: { type: string }
    PropTemplate:
      type: object
      description: A buyable challenge / funded template.
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string, nullable: true }
        accountSizeUsd: { type: string }
        durationDays: { type: integer, nullable: true }
        feeUsd: { type: string }
        payoutSplitPct: { type: number, nullable: true }
        programKind: { type: string, enum: [challenge, flash] }
        capitalModel: { type: string, enum: [entry_fee, buying_power] }
        supportedModes: { type: string, enum: [paper_only, live_only, both] }
        active: { type: boolean }
        rules:
          type: array
          description: >-
            The template's rule set. Always populated, whichever way it was
            written — if you sent `riskProfileId`, these are that profile's
            rules, copied down at write time.
          items: { type: object, additionalProperties: true }
        riskProfileId:
          type: string
          nullable: true
          description: >-
            The risk profile holding this template's rules. When you write
            `rules`, the server materializes a MANAGED profile for you and this
            is its id — you never create or bind it yourself. When you write
            `riskProfileId`, it is the shared profile you named.
      additionalProperties: true
    PropTemplateWrite:
      type: object
      description: |
        Create / update body. On create `id`, `name`, `accountSizeUsd`, `feeUsd`,
        `payoutSplitPct`, `tradingGroupId`, and exactly one of `rules` /
        `riskProfileId` are required; all are optional on PUT. Mirrors the admin
        template shape (tierLadder, economics, nextStepTemplateId, programKind, …)
        — see the admin docs for the full field set; extra fields are accepted.

        **Rules: `rules` XOR `riskProfileId`.** A template has one source of
        truth for its rules, so sending both is `400 rules_and_profile_conflict`
        rather than one silently winning.

        - `rules` — the normal path. The server keeps a **managed** risk profile
          in sync with them and binds it, so an account created from this
          template carries the same rules on the risk plane that the prop engine
          enforces. You do not create, name, bind, or edit that profile; direct
          writes to it are refused with `409 managed_by_template`.
        - `riskProfileId` — reuse an existing **shared** profile across several
          templates. Its rules are copied down into `rules` at write time. While
          a template reads a shared profile, patching `rules` is
          `400 rules_locked_by_profile`; send `riskProfileId: null` to take the
          rules back onto the template (they become managed again).

        Other errors: `400 risk_profile_not_found` (not in your tenant),
        `400 invalid_profile_kind` (must be `challenge` or `funded`),
        `400 profile_is_managed` (that profile belongs to another template),
        `400 profile_rules_incompatible` (a template needs 1..32 rules),
        `400 rules_required` (create with neither).
      properties:
        id: { type: string, description: lower-kebab-case slug (create only) }
        name: { type: string, maxLength: 120 }
        description: { type: string, nullable: true }
        accountSizeUsd: { type: string, description: USD decimal string }
        durationDays: { type: integer, nullable: true }
        feeUsd: { type: string }
        payoutSplitPct: { type: string, description: '0..100 decimal string' }
        rules:
          type: array
          minItems: 1
          maxItems: 32
          description: >-
            This template's rules. Mutually exclusive with `riskProfileId`.
            Sending them makes the server keep a managed risk profile in sync.
          items: { type: object, additionalProperties: true }
        riskProfileId:
          type: string
          nullable: true
          description: >-
            Reuse an existing shared risk profile (kind `challenge` or `funded`)
            instead of sending `rules`. `null` on PUT moves the rules back onto
            the template under a managed profile.
        programKind: { type: string, enum: [challenge, flash, CHALLENGE, FLASH] }
        tradingGroupId: { type: string, nullable: true, description: required on create }
        propTenantId: { type: string, nullable: true, description: one of your prop firms }
        nextStepTemplateId: { type: string, nullable: true, description: same-firm, acyclic; mutually exclusive with tierLadder }
        tierLadder: { type: object, nullable: true, additionalProperties: true }
        economics: { type: object, nullable: true, additionalProperties: true }
        active: { type: boolean }
      additionalProperties: true
    TradingGroup:
      type: object
      description: A trading group — scopes fees / leverage / venue+symbol allowlists / risk limits.
      properties:
        id: { type: string, format: uuid }
        slug: { type: string }
        name: { type: string }
        description: { type: string, nullable: true }
        isDefault: { type: boolean }
        accountCount: { type: integer }
        config:
          type: object
          description: 'Fees (maker/taker bps, futures commission), leverage caps, venuesAllowlist, symbolsAllowlist, riskLimits, per-symbol/per-venue overrides.'
          additionalProperties: true
      additionalProperties: true
    TradingGroupCreate:
      type: object
      required: [slug, name]
      properties:
        slug: { type: string, description: kebab-case, unique per tenant, max 32 }
        name: { type: string, maxLength: 64 }
        description: { type: string }
        config: { type: object, additionalProperties: true }
    TradingGroupPatch:
      type: object
      properties:
        name: { type: string, maxLength: 64 }
        description: { type: string, nullable: true }
        config: { type: object, additionalProperties: true, description: replaced wholesale when present }
    PropAccount:
      type: object
      description: A prop challenge / funded account.
      properties:
        id: { type: string, format: uuid }
        accountId: { type: string, format: uuid }
        templateId: { type: string }
        status:
          type: string
          enum: [pending_payment, pending_agreement, active, passed, paid_out, breached, expired, cancelled]
        enforcementState: { type: string, nullable: true, enum: [reduce_only, soft_stop_day, lock_cycle] }
        accountSizeUsd: { type: string }
        tierLevel: { type: integer, nullable: true }
      additionalProperties: true
    Firm:
      type: object
      description: A firm (prop-tenant) — the operator's PropInstance registry row.
      properties:
        id: { type: string }
        tenantId: { type: string }
        name: { type: string }
        kind: { type: string }
        parentPropTenantId: { type: string, nullable: true }
        instanceAddress: { type: string, nullable: true, description: On-chain PropInstance address (null until deployed). }
        multisigAddress: { type: string, nullable: true }
        chainId: { type: integer }
        deployedAt: { type: string, format: date-time, nullable: true }
        feeConfigJson:
          type: object
          nullable: true
          description: Fee schedule with the *PlatformShareBps wire aliases (paper-shadow firms are null).
          additionalProperties: true
        status: { type: string, description: 'pending_deploy | active | … the firm lifecycle status.' }
        logoUrl: { type: string, nullable: true }
        supportEmail: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    FirmCreate:
      type: object
      required: [name, multisigAddress, feeConfig]
      description: Create body for POST /api/v1/firms. No tenantId — it is forced from the caller's session.
      properties:
        name: { type: string, minLength: 1, maxLength: 80 }
        multisigAddress: { type: string, pattern: '^0x[a-fA-F0-9]{40}$' }
        feeConfig:
          type: object
          description: Platform fee schedule (entry/payout fee bps + breach/expiry/quit forfeiture + platform-share dials + minEntryFeeUnits).
          additionalProperties: true
        chainId: { type: integer, default: 42161 }
        logoUrl: { type: string, format: uri, nullable: true }
        supportEmail: { type: string, format: email, nullable: true }
        withPaperShadow: { type: boolean, default: true, description: Also create a paper-shadow sibling. }
    FirmDeployBody:
      type: object
      description: Deploy options for POST /api/v1/firms/{id}/deploy.
      properties:
        target: { type: string, enum: [mainnet, sepolia], description: Chain to deploy to (defaults to the server's PROP_DEPLOY_TARGET). }
        dryRun: { type: boolean, default: false, description: Preview calldata + preflight without broadcasting. }
    FirmReadiness:
      type: object
      description: A firm's go-live readiness checklist (shared by the admin + provider surfaces).
      properties:
        propTenantId: { type: string }
        name: { type: string }
        kind: { type: string }
        status: { type: string }
        target: { type: string }
        chainId: { type: integer }
        ready: { type: boolean, description: True only when every required check passes. }
        checks:
          type: array
          items:
            type: object
            properties:
              key: { type: string }
              label: { type: string }
              ok: { type: boolean }
              detail: { type: string, nullable: true }
              required: { type: boolean }
    RiskProfile:
      type: object
      description: |
        A kind-typed risk profile (the order-time + state-plane rule set).

        Two origins. An **authored** profile (`managedByTemplateId: null`) is one
        you created here and may bind to accounts or share across prop templates.
        A **managed** profile is one the server materialized from a prop
        template's `rules`; it belongs to that template, is read-only on this
        surface, and is enforced by the prop engine rather than the profile
        plane. Edit it through `PATCH /api/v1/prop-templates/{id}`.
      properties:
        id: { type: string, format: uuid }
        tenantId: { type: string }
        name: { type: string }
        kind: { type: string, enum: [trader, challenge, funded] }
        enforcement: { type: string, enum: [off, soft, hard] }
        settlement: { type: string, enum: [offchain, onchain] }
        active: { type: boolean }
        managedByTemplateId:
          type: string
          nullable: true
          description: >-
            Non-null when this profile is owned by that prop template. Such a
            profile is read-only here — PATCH and archive return
            `409 managed_by_template`. Edit the template's `rules` instead.
        archivedAt: { type: string, format: date-time, nullable: true }
        rules:
          type: array
          description: Rule objects (daily_loss_cap_usd, max_drawdown_usd, max_leverage, allowed_symbols, …), each with an optional per-rule `action` (warn / reduce_only / soft_stop_day / lock_cycle / terminate).
          items: { type: object, additionalProperties: true }
      additionalProperties: true
    RiskProfileWrite:
      type: object
      description: Create / update body. `name` + `kind` required on create; all fields optional on PATCH.
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        kind: { type: string, enum: [trader, challenge, funded] }
        enforcement: { type: string, enum: [off, soft, hard] }
        settlement: { type: string, enum: [offchain, onchain] }
        active: { type: boolean }
        rules:
          type: array
          maxItems: 64
          items: { type: object, additionalProperties: true }
    Venue:
      type: string
      enum: [hyperliquid, aster, polymarket, massive, b3, paper]
      description: >-
        Trading venues (hyperliquid, aster, polymarket) plus 'massive' — the
        Massive (Polygon) FUTURES market-data source. 'massive' is DATA-ONLY:
        valid for market-data + /api/v1/symbols/{venue} discovery, NOT for
        order placement (the compose/order routes reject it). 'b3' (Brazilian
        exchange, Nuntius-fed) is discoverable + PAPER-tradeable: valid for
        /api/v1/symbols/{venue} and paper order placement; live B3 dispatch is
        not built yet. Track B.5 adds 'cme', 'ibkr'.


        'paper' is NOT an exchange: it is the sentinel our matching engine
        stamps on rows it filled itself, with no order placed anywhere. It
        covers both the sandbox and internalised (B-booked) real-money
        accounts, so it does not by itself tell you whether the money is
        real — see "Execution model" in the API description. Like 'massive'
        it is a partial member of this enum: 'paper' is RESPONSE-ONLY (it
        appears on positions / orders / fills / per-venue balances) and is
        rejected as a market-data path parameter, since there is no paper
        price feed — the paper path prices off the symbol's own venue.

    Error:
      type: object
      required: [error]
      properties:
        error: { type: string, description: 'Stable machine-readable code' }
        detail: { type: string, description: 'Human-readable explanation' }

    BootstrapRequest:
      type: object
      required: [apiKey, apiSecret]
      properties:
        apiKey: { type: string }
        apiSecret: { type: string }

    BootstrapResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            accountId: { type: string }
            systemAccess: { type: string, enum: [readonly, liquidation, fullTrading] }
            tokenExpiresAt: { type: integer, description: 'Unix ms' }
            riskEngineWss:
              type: object
              properties:
                token: { type: string }
                expiresAt: { type: integer }
            quoteServiceWss:
              type: object
              properties:
                token: { type: string }
                expiresAt: { type: integer }

    ApiTokenRequest:
      type: object
      required: [apiKey, apiSecret]
      properties:
        apiKey: { type: string }
        apiSecret: { type: string }

    ApiTokenOAuthRequest:
      type: object
      description: 'OAuth 2.0 form body. Send grant_type=client_credentials with client_id/client_secret, or grant_type=refresh_token with refresh_token.'
      required: [grant_type]
      properties:
        grant_type: { type: string, enum: [client_credentials, refresh_token] }
        client_id: { type: string, description: 'apiKey (client_credentials grant; or via HTTP Basic).' }
        client_secret: { type: string, description: 'apiSecret (client_credentials grant; or via HTTP Basic).' }
        refresh_token: { type: string, description: 'The rotating refresh token (refresh_token grant).' }

    ApiTokenResponse:
      type: object
      description: 'Carries legacy fields + standard OAuth 2.0 token-response fields.'
      properties:
        token: { type: string, description: 'JWT for Authorization: Bearer header (legacy field; == access_token).' }
        tier: { type: string, enum: [readonly, liquidation, fullTrading] }
        accountId: { type: string }
        expiresAt: { type: integer, description: 'Unix ms' }
        enabledTools:
          type: array
          nullable: true
          items: { type: string }
          description: 'MCP tool allowlist; null = all tier-permitted, [] = reads-only.'
        access_token: { type: string, description: 'OAuth 2.0 access token (same value as token).' }
        token_type: { type: string, enum: [Bearer] }
        expires_in: { type: integer, description: 'Access-token lifetime in seconds.' }
        scope: { type: string, description: 'e.g. mcp:fullTrading' }
        refresh_token: { type: string, description: '30-day rotating refresh token; exchange via grant_type=refresh_token.' }

    PlaceOrderRequest:
      type: object
      required: [venue, symbol, side, type, qty]
      properties:
        accountId: { type: string, description: 'Target account. Optional — defaults to the credential''s active account. When set it must be an account you own (e.g. a provisioned paper child); bearer credentials have no active-account switch, so this is how you place on a specific owned account. paper-vs-live routing keys off this account.' }
        venue: { type: string, enum: [hyperliquid, aster, polymarket, kalshi, paper], description: 'Order placement on hyperliquid + aster. polymarket + kalshi are also composable through the OMS but their live dispatch is flag-gated (polymarketTrading / kalshiTrading+kalshiLiveOrders, default OFF) — a disabled venue returns polymarket_trading_disabled / kalshi_live_unverified. Use "paper" when the target account is matched by our own engine rather than routed to an exchange — the sandbox testnet, and internalised (B-booked) real-money accounts, which settle real PnL against the firm as counterparty. Paper B3 futures/equities also use "paper": the paper path resolves the .B3 pricing venue from the symbol. Responses from a "paper" placement carry no exchange order id; see "Execution model" in the API description.' }
        symbol: { type: string, example: 'BTC.HL' }
        side: { type: string, enum: [buy, sell] }
        type: { type: string, enum: [market, limit, stop, stop_limit, take_profit], description: 'trailing_stop is a deliberate permanent defer; not accepted.' }
        qty:
          oneOf: [{ type: string }, { type: number }]
          description: 'Order size — decimal string (preferred, decimal-safe) or number; server coerces.'
        price:
          oneOf: [{ type: string }, { type: number }]
          description: 'Required for limit / stop_limit. Decimal string or number.'
        triggerPrice:
          oneOf: [{ type: string }, { type: number }]
          description: 'Required for stop / stop_limit / take_profit.'
        stopLimitPrice:
          oneOf: [{ type: string }, { type: number }]
        reduceOnly: { type: boolean }
        timeInForce: { type: string, enum: [Gtc, Ioc, Alo], description: 'Also accepted under the legacy field name `tif`.' }
        tif: { type: string, enum: [Gtc, Ioc, Alo], description: 'Legacy alias for timeInForce; normalized in the handler.' }
        leverage:
          oneOf: [{ type: string }, { type: number }]
          description: 'Intended leverage (max 1000); checked at compose-time against the effective per-venue cap. Decimal string or number.'
        asterTimeInForce: { type: string, enum: [GTC, IOC, FOK, GTX], description: 'Aster-only (case-sensitive).' }
        asterKind: { type: string, enum: [tp, sl], description: 'Aster-only bracket-leg kind.' }
        callbackRate: { type: number, minimum: 0.1, maximum: 5, description: 'Aster-only trailing-stop callback rate (percent).' }
        activationPrice: { type: number, description: 'Aster-only trailing-stop activation price.' }
        clientId: { type: string, minLength: 1, maxLength: 120, description: 'Aster-only venue client order id (idempotency).' }
        clientOrderId: { type: string, minLength: 1, maxLength: 64, description: 'PERF-P4 — opaque FE-minted correlation id for synchronous placement optimism. Distinct from clientId (no venue-side effect); echoed back on this response and on the /ws/risk Open-Orders-Update / Order-Intent-Update frames so a client can adopt an optimistic working row by id.' }
        attestationHash: { type: string, pattern: '^[0-9a-fA-F]{64}$', description: 'Optional 64-char hex attestation (DEX signing).' }
        validForMs: { type: integer, maximum: 120000, description: 'Order validity window in ms (max 120000).' }
        parentIntentId: { type: string, format: uuid, description: 'Bracket-child linkage — the entry intent id (#431).' }
      description: 'No inline bracket on compose — attach OCO legs via POST /api/v1/oms/brackets/attach.'

    AttachBracketsRequest:
      type: object
      required: [parentIntentId]
      description: 'At least one of tpPrice / slPrice must be set.'
      properties:
        parentIntentId: { type: string, description: 'order_intents id of the entry the bracket attaches to.' }
        tpPrice: { type: number, description: 'Take-profit limit price. Omit to skip the TP leg.' }
        slPrice: { type: number, description: 'Stop-loss trigger price. Omit to skip the SL leg.' }

    AttachBracketsResponse:
      type: object
      required: [staged, tpIntentId, slIntentId]
      properties:
        staged: { type: boolean, description: 'true = parent unfilled, legs parked + arm on fill (OCO-cancel if it terminates unfilled). false = parent already filled, legs dispatched immediately.' }
        tpIntentId: { type: string, nullable: true, description: 'Intent id of the TP leg, or null when no TP was requested.' }
        slIntentId: { type: string, nullable: true, description: 'Intent id of the SL leg, or null when no SL was requested.' }

    ChaseRequest:
      type: object
      required: [intentId]
      properties:
        intentId: { type: string, format: uuid, description: 'order_intents id of the working order to chase (cancel + re-peg to market).' }

    ChaseResponse:
      type: object
      required: [ok, cancelIntentId, newIntentId, reduceOnly, side, qty]
      properties:
        ok: { type: boolean, enum: [true] }
        cancelIntentId: { type: string, description: 'Cancel intent id for the chased order (paper: the order''s own id — native cancel).' }
        newIntentId: { type: string, description: 'The re-pegged marketable replacement intent id.' }
        reduceOnly: { type: boolean, description: 'Carried verbatim from the chased order — a reduce-only closer stays reduce-only.' }
        side: { type: string, enum: [buy, sell], description: 'Same side as the chased order.' }
        qty: { type: string, description: 'The remaining (unfilled) size that was re-pegged.' }

    AttachToPositionRequest:
      type: object
      required: [symbol]
      description: 'At least one of tpPrice / slPrice must be set.'
      properties:
        symbol: { type: string, description: 'The open position''s symbol (e.g. BTC.HL).', example: 'BTC.HL' }
        tpPrice: { type: number, description: 'Take-profit limit price. Omit to skip the TP leg.' }
        slPrice: { type: number, description: 'Stop-loss trigger price. Omit to skip the SL leg.' }

    AttachToPositionResponse:
      type: object
      required: [ok, ocoGroupId, tpIntentId, slIntentId, side, qty]
      properties:
        ok: { type: boolean, enum: [true] }
        ocoGroupId: { type: string, description: 'The shared OCO group id both legs carry as parentIntentId — the linkage bracketLifecycle keys on to cancel the survivor on fire + both on position close.' }
        tpIntentId: { type: string, nullable: true, description: 'Intent id of the TP leg, or null when no TP was requested.' }
        slIntentId: { type: string, nullable: true, description: 'Intent id of the SL leg, or null when no SL was requested.' }
        side: { type: string, enum: [buy, sell], description: 'Closing side of the legs (long position → sell, short → buy).' }
        qty: { type: string, description: 'The protected position size (each leg is full-size + reduce-only).' }

    ModifyBracketsRequest:
      type: object
      required: [parentIntentId]
      description: 'Re-price an entry''s still-staged TP/SL legs in place. At least one of tpPrice / slPrice must be set, each on the correct side of the entry.'
      properties:
        parentIntentId: { type: string, description: 'order_intents id of the entry whose staged legs are re-priced.' }
        tpPrice: { type: number, description: 'New take-profit limit price. Omit to leave the TP leg unchanged.' }
        slPrice: { type: number, description: 'New stop-loss trigger price. Omit to leave the SL leg unchanged.' }

    ModifyBracketsResponse:
      type: object
      required: [ok, modified]
      properties:
        ok: { type: boolean, enum: [true] }
        modified: { type: integer, description: 'Number of staged legs re-priced in place.' }

    PlaceOrderResponse:
      type: object
      required: [intentId]
      properties:
        intentId: { type: string }
        state: { type: string, description: 'Not returned by the live 201 ({ intentId } only); state transitions stream over /ws/risk.' }
        clientOrderId: { type: string, description: 'PERF-P4 — echoed back verbatim when the request supplied one, so the caller can correlate this response with its optimistic row before the /ws/risk frames arrive.' }

    CancelOrderRequest:
      type: object
      properties:
        intentId: { type: string }
        venueOrderId: { type: string }
        accountId: { type: string }
      description: >-
        Provide either intentId OR venueOrderId. The internally-minted
        venueOrderId returned for an internalised (B-booked) order is a valid
        cancel key here — it addresses the resting order in our own engine,
        which is where it lives. See "Execution model".

    CancelOrderResponse:
      type: object
      properties:
        ok: { type: boolean }
        venueOrderId: { type: string }

    ModifyOrderRequest:
      type: object
      required: [intentId]
      properties:
        intentId: { type: string, format: uuid, description: 'The working order intent to modify. Must be in your account.' }
        newPrice: { type: number }
        newQty: { type: number }
        newTriggerPrice: { type: number }
        newStopLimitPrice: { type: number }
        validForMs: { type: integer, maximum: 120000, description: 'Order validity window in ms (max 120000).' }

    AccountState:
      type: object
      properties:
        accountId: { type: string }
        accountNumber: { type: string }
        perVenue:
          type: array
          description: 'One entry per venue the account holds; aggregate client-side for a single rollup.'
          items: { $ref: '#/components/schemas/AccountVenueState' }

    AccountVenueState:
      type: object
      properties:
        venue:
          $ref: '#/components/schemas/Venue'
          description: >-
            Routing key. 'paper' means this balance rolls up executions our
            own engine matched rather than an exchange — sandbox OR an
            internalised (B-booked) real-money account. See "Execution model".
        exchange:
          type: string
          description: >-
            Human exchange name for display. Futures never surface the internal
            routing venue (the data provider); `venue` keeps the routing key
            while this carries the exchange. For per-venue rows with no
            instrument, futures resolve to the 'Futures' bucket; for every other
            venue `exchange == venue`.
        equityUsd: { type: string }
        balanceUsd: { type: string }
        marginUsedUsd: { type: string }
        marginAvailableUsd: { type: string }
        unrealizedPnlUsd: { type: string }
        source: { type: string }
        lastVenueSnapshotAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Position:
      type: object
      properties:
        venue:
          $ref: '#/components/schemas/Venue'
          description: >-
            Routing key. 'paper' means the position was opened by fills our own
            engine matched, with no order placed at any exchange — sandbox OR
            an internalised (B-booked) real-money account. See "Execution
            model" in the API description.
        exchange:
          type: string
          description: >-
            Human exchange name for display (CME/CBOT/NYMEX/COMEX for futures,
            else equal to `venue`). Resolved from the symbol, so correct even for
            paper-routed futures whose `venue` is 'paper'. The data provider is
            never surfaced.
        symbol: { type: string }
        side: { type: string, enum: [long, short] }
        qty: { type: string }
        entryPrice: { type: string }
        markPrice: { type: string, nullable: true }
        liquidationPrice: { type: string, nullable: true }
        leverage: { type: number, nullable: true }
        openedAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Order:
      type: object
      properties:
        id: { type: string }
        accountId: { type: string }
        venue:
          $ref: '#/components/schemas/Venue'
          description: >-
            Routing key; see Position.venue. 'paper' means the order was
            matched by our engine and no exchange order exists for it.
        exchange: { type: string, description: 'Human exchange name for display; see Position.exchange.' }
        venueOrderId:
          type: string
          nullable: true
          description: >-
            The exchange's own order id on a routed order. Null before the
            order is dispatched, and null if it never was. On an internalised
            (B-booked) order — `venue` 'paper' — this is an id WE minted for
            the internal match: stable and safe to dedup on, but it identifies
            no order at any exchange, because none was placed. See "Execution
            model" in the API description.
        symbol: { type: string }
        side: { type: string, enum: [buy, sell] }
        type: { type: string }
        qty: { type: string }
        price: { type: string, nullable: true }
        triggerPrice: { type: string, nullable: true }
        reduceOnly: { type: boolean, nullable: true }
        composedAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        state:
          type: string
          enum:
            - intent_pending
            - signed_pending_dispatch
            - dispatched
            - acknowledged
            - partially_filled
            - filled
            - canceled
            - rejected
            - replaced
            - expired

    OrderListResponse:
      type: object
      properties:
        orders:
          type: array
          items: { $ref: '#/components/schemas/Order' }
        nextCursor: { type: string, nullable: true }
        hasMore: { type: boolean }

    Trade:
      type: object
      properties:
        id: { type: string }
        exchange:
          type: string
          nullable: true
          description: >-
            Human exchange name for display; see Position.exchange. Null
            mirrors a null venue. Reads 'paper' on an internalised (B-booked)
            round-trip — the firm was the counterparty on both legs and no
            exchange was involved. The PnL is still real money. See "Execution
            model" in the API description.
        symbol: { type: string }
        side: { type: string, enum: [long, short] }
        qty: { type: string }
        entryPrice: { type: string }
        exitPrice: { type: string }
        pnl: { type: string }
        pnlPct: { type: string }
        exitTime: { type: string, format: date-time }

    TradeListResponse:
      type: object
      properties:
        trades:
          type: array
          items: { $ref: '#/components/schemas/Trade' }
        nextCursor: { type: string, nullable: true }
        hasMore: { type: boolean }

    Candle:
      type: object
      properties:
        t: { type: integer, description: 'Open time, Unix ms' }
        o: { type: string }
        h: { type: string }
        l: { type: string }
        c: { type: string }
        v: { type: string }

    FundingListResponse:
      type: object
      properties:
        funding:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              kind:
                type: string
                enum: [deposit, withdraw, transfer_in, transfer_out, spot_transfer, internal_transfer]
              venue: { $ref: '#/components/schemas/Venue' }
              exchange: { type: string, description: 'Human exchange name for display; futures with no instrument resolve to the ''Futures'' bucket.' }
              asset: { type: string }
              amount: { type: string }
              feeAmount: { type: string, nullable: true }
              feeAsset: { type: string, nullable: true }
              status: { type: string }
              eventAt: { type: string, format: date-time }
              counterparty: { type: string, nullable: true }
              sourceHash: { type: string, nullable: true }
        nextCursor: { type: string, nullable: true }
        hasMore: { type: boolean }

    PropfirmUserCreate:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
        displayName: { type: string }
        name: { type: string }
        surname: { type: string }
        phoneNumber: { type: string }
        address: { type: string }
        postalCode: { type: string }
        city: { type: string }
        state: { type: string }
        country: { type: string, minLength: 2, maxLength: 2 }
        language: { type: string, minLength: 2, maxLength: 2 }

    TradingRule:
      type: object
      properties:
        id: { type: string }
        tenantId: { type: string }
        accountId: { type: string, nullable: true }
        tradingGroupId: { type: string }
        name: { type: string }
        description: { type: string, nullable: true }
        enabled: { type: boolean }
        maximumDrawdownUsd: { type: string, nullable: true }
        runUpTargetUsd: { type: string, nullable: true }
        intradayDrawdownUsd: { type: string, nullable: true }
        intradayRunupUsd: { type: string, nullable: true }
        maxPositionLossUsd: { type: string, nullable: true }
        maxPositionGainUsd: { type: string, nullable: true }
        maxPortfolioLossUsd: { type: string, nullable: true }
        maxPortfolioGainUsd: { type: string, nullable: true }
        maxDailyTrades: { type: integer, nullable: true }
        minSessionCount: { type: integer, nullable: true }
        overnightAllowed: { type: boolean }
        overweekAllowed: { type: boolean }
        violationAction:
          type: string
          enum: [none, challenge_fail, flatten, intraday_disable]
        perSymbol: { type: object, additionalProperties: true }
        archivedAt: { type: string, format: date-time, nullable: true }

    TradingRuleCreate:
      allOf:
        - $ref: '#/components/schemas/TradingRule'
      required: [name, tradingGroupId]

    WebhookSubscription:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        apiKey: { type: string }
        categories:
          type: array
          description: >-
            Full delivery-category contract. Only the LIVE subset
            (account.created, account.balance_changed,
            account.status_changed, trade.closed, tenant.plan_changed,
            tenant.kyb_changed) is subscribable — the create/patch API
            rejects the RESERVED categories (position.overnight,
            subscription.created, subscription.updated) because nothing
            emits them yet. Reserved values remain here for forward-compat
            and may appear on historical rows.
          items:
            type: string
            enum:
              - account.created
              - account.balance_changed
              - account.status_changed
              - position.overnight
              - subscription.created
              - subscription.updated
              - trade.closed
              - tenant.plan_changed
              - tenant.kyb_changed
        enabled: { type: boolean }
        maxAttempts: { type: integer }
        autoDisabledAt: { type: string, format: date-time, nullable: true }
        autoDisableReason: { type: string, nullable: true }

    WebhookSubscriptionCreate:
      type: object
      required: [url, apiKey]
      properties:
        url: { type: string, format: uri, pattern: '^https://' }
        apiKey: { type: string, minLength: 8 }
        categories:
          type: array
          items: { type: string }
        maxAttempts: { type: integer, minimum: 1, maximum: 20 }
        authHeader: { type: string }

    # ── Analytics ──────────────────────────────────────────────
    AnalyticsBucketStat:
      type: object
      properties:
        count: { type: integer }
        pnlUsd: { type: number }
        volumeUsd: { type: number }
    AnalyticsTradeStats:
      type: object
      properties:
        count: { type: integer }
        wins: { type: integer }
        losses: { type: integer }
        winRate: { type: number }
        grossPnlUsd: { type: number }
        netPnlUsd: { type: number }
        totalFeesUsd: { type: number }
        avgHoldingMs: { type: integer }
        maxWinUsd: { type: number }
        maxLossUsd: { type: number }
    AnalyticsVolumeStats:
      type: object
      properties:
        totalUsd: { type: number }
        byVenue:
          type: object
          additionalProperties: { $ref: '#/components/schemas/AnalyticsBucketStat' }
        bySide:
          type: object
          properties:
            long: { $ref: '#/components/schemas/AnalyticsBucketStat' }
            short: { $ref: '#/components/schemas/AnalyticsBucketStat' }
    AnalyticsPerformance:
      type: object
      properties:
        window: { type: string }
        sinceMs: { type: integer, format: int64 }
        accountId: { type: string, description: Omitted on the empty (no-wallet) response. }
        trades: { $ref: '#/components/schemas/AnalyticsTradeStats' }
        volume: { $ref: '#/components/schemas/AnalyticsVolumeStats' }
        streak:
          type: object
          properties:
            type: { type: string, nullable: true, enum: [win, loss] }
            count: { type: integer }
    AnalyticsSymbolRow:
      type: object
      properties:
        symbol: { type: string }
        venue: { type: string, nullable: true }
        count: { type: integer }
        wins: { type: integer }
        losses: { type: integer }
        winRate: { type: number }
        pnlUsd: { type: number }
        volumeUsd: { type: number }
        avgHoldingMs: { type: integer }
    AnalyticsPerSymbol:
      type: object
      properties:
        window: { type: string }
        sinceMs: { type: integer, format: int64 }
        symbols:
          type: array
          items: { $ref: '#/components/schemas/AnalyticsSymbolRow' }
    PropDiagnosis:
      type: object
      properties:
        propAccountId: { type: string }
        status: { type: string }
        templateId: { type: string, nullable: true }
        templateName: { type: string }
        programKind: { type: string }
        startedAt: { type: string, format: date-time, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
        closedAt: { type: string, format: date-time, nullable: true }
        closedReason: { type: string, nullable: true }
        payoutOwedUsd: { oneOf: [{ type: string }, { type: number }] }
        recentTrades:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              symbol: { type: string }
              venue: { type: string, nullable: true }
              pnl: { type: string, nullable: true }
              exitTime: { type: string, format: date-time }
        recommendation: { type: string, enum: [continue, pause, closed, claim_ready] }
    EquityCurvePoint:
      type: object
      properties:
        time: { type: integer, format: int64, description: Epoch ms }
        equity: { type: number }
    AnalyticsEquityCurve:
      type: object
      properties:
        accountId: { type: string }
        window: { type: string }
        points: { type: integer }
        capped: { type: boolean }
        curve:
          type: array
          items: { $ref: '#/components/schemas/EquityCurvePoint' }
    RiskMetrics:
      type: object
      description: Annualized metrics are null when the window is too short to annualize.
      properties:
        samples: { type: integer }
        startEquity: { type: number, nullable: true }
        endEquity: { type: number, nullable: true }
        totalReturnPct: { type: number, nullable: true }
        annualizedReturnPct: { type: number, nullable: true }
        maxDrawdownPct: { type: number, nullable: true }
        volatilityPct: { type: number, nullable: true }
        sharpe: { type: number, nullable: true }
        sortino: { type: number, nullable: true }
        calmar: { type: number, nullable: true }
    AnalyticsRiskMetrics:
      type: object
      properties:
        accountId: { type: string }
        window: { type: string }
        capped: { type: boolean }
        metrics: { $ref: '#/components/schemas/RiskMetrics' }

    # ── Indicators ─────────────────────────────────────────────
    IndicatorTemplate:
      type: string
      enum: [sma, ema, rsi, macd, bollinger]
    Indicator:
      type: object
      properties:
        id: { type: string }
        accountId: { type: string }
        name: { type: string }
        templateId: { $ref: '#/components/schemas/IndicatorTemplate' }
        params: { type: object, additionalProperties: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    CreateIndicatorRequest:
      type: object
      required: [name, templateId]
      properties:
        name: { type: string, minLength: 1, maxLength: 64 }
        templateId: { $ref: '#/components/schemas/IndicatorTemplate' }
        params:
          type: object
          additionalProperties: true
          description: Template-specific; validated server-side. Omit to accept defaults.
    UpdateIndicatorRequest:
      type: object
      properties:
        name: { type: string, minLength: 1, maxLength: 64 }
        params: { type: object, additionalProperties: true }
    IndicatorTemplateMeta:
      type: object
      properties:
        id: { $ref: '#/components/schemas/IndicatorTemplate' }
        label: { type: string }
        params:
          type: object
          additionalProperties: { type: string }
          description: Human-readable param shape.

    # ── Backtests ──────────────────────────────────────────────
    BacktestRef:
      description: An indicator output name (string) or a literal number.
      oneOf:
        - { type: number }
        - { type: string }
    BacktestCondition:
      type: object
      description: Comparison (gt/lt/cross…) or a logical and/or nesting other conditions.
      required: [type]
      properties:
        type:
          type: string
          enum: [gt, gte, lt, lte, cross_above, cross_below, and, or]
        left: { $ref: '#/components/schemas/BacktestRef' }
        right: { $ref: '#/components/schemas/BacktestRef' }
        conditions:
          type: array
          minItems: 1
          maxItems: 10
          items: { $ref: '#/components/schemas/BacktestCondition' }
    BacktestIndicatorDef:
      type: object
      required: [id, template]
      properties:
        id: { type: string, maxLength: 32 }
        template: { $ref: '#/components/schemas/IndicatorTemplate' }
        params:
          type: object
          additionalProperties: { oneOf: [{ type: number }, { type: string }] }
        output: { type: string, maxLength: 16 }
    BacktestSpec:
      type: object
      required: [venue, symbol, interval, side, entry, exit, sizeUsd]
      properties:
        venue: { type: string, enum: [hyperliquid, aster] }
        symbol: { type: string, maxLength: 64 }
        interval: { type: string, maxLength: 8, example: 1h }
        side: { type: string, enum: [long, short] }
        indicators:
          type: array
          maxItems: 10
          items: { $ref: '#/components/schemas/BacktestIndicatorDef' }
        entry: { $ref: '#/components/schemas/BacktestCondition' }
        exit: { $ref: '#/components/schemas/BacktestCondition' }
        sizeUsd: { type: number, exclusiveMinimum: 0 }
    BacktestRunRequest:
      type: object
      required: [spec]
      properties:
        spec: { $ref: '#/components/schemas/BacktestSpec' }
        from: { type: integer, format: int64, description: Epoch ms window start }
        to: { type: integer, format: int64, description: Epoch ms window end }
        limit: { type: integer, minimum: 2, maximum: 1500, default: 500 }
        params:
          type: object
          properties:
            startingBalanceUsd: { type: number }
            feeBps: { type: number, minimum: 0, maximum: 100 }
            slippageBps: { type: number, minimum: 0, maximum: 1000 }
    BacktestSummary:
      type: object
      properties:
        trades: { type: integer }
        wins: { type: integer }
        losses: { type: integer }
        winRatePct: { type: number, nullable: true }
        netPnlUsd: { type: number }
        startingBalanceUsd: { type: number }
        endingEquityUsd: { type: number }
        totalReturnPct: { type: number, nullable: true }
        maxDrawdownPct: { type: number, nullable: true }
        volatilityPct: { type: number, nullable: true }
        sharpe: { type: number, nullable: true }
        sortino: { type: number, nullable: true }
        calmar: { type: number, nullable: true }
        openAtEnd: { type: boolean }
    BacktestFill:
      type: object
      properties:
        time: { type: integer, format: int64 }
        kind: { type: string, enum: [entry, exit] }
        side: { type: string, enum: [buy, sell] }
        price: { type: number }
        qty: { type: number }
        feeUsd: { type: number }
        pnlUsd: { type: number, description: Realized, on exits. }
    BacktestSignal:
      type: object
      properties:
        time: { type: integer, format: int64 }
        kind: { type: string, enum: [entry, exit] }
    BacktestResult:
      type: object
      properties:
        id: { type: string, nullable: true, description: Null if best-effort persist failed. }
        venue: { type: string }
        symbol: { type: string }
        interval: { type: string }
        from: { type: integer, format: int64 }
        to: { type: integer, format: int64 }
        bars: { type: integer }
        summary: { $ref: '#/components/schemas/BacktestSummary' }
        equityCurve:
          type: array
          items: { $ref: '#/components/schemas/EquityCurvePoint' }
        fills:
          type: array
          items: { $ref: '#/components/schemas/BacktestFill' }
        signals:
          type: array
          items: { $ref: '#/components/schemas/BacktestSignal' }
        warnings:
          type: array
          items: { type: string }
    BacktestRunSummary:
      type: object
      properties:
        id: { type: string }
        venue: { type: string }
        symbol: { type: string }
        interval: { type: string }
        side: { type: string, enum: [long, short] }
        bars: { type: integer }
        from: { type: integer, format: int64 }
        to: { type: integer, format: int64 }
        summary: { $ref: '#/components/schemas/BacktestSummary' }
        createdAt: { type: string, format: date-time }
    BacktestListResponse:
      type: object
      properties:
        runs:
          type: array
          items: { $ref: '#/components/schemas/BacktestRunSummary' }
        count: { type: integer }
    BacktestRunDetail:
      type: object
      properties:
        id: { type: string }
        accountId: { type: string }
        tenantId: { type: string }
        venue: { type: string }
        symbol: { type: string }
        interval: { type: string }
        side: { type: string, enum: [long, short] }
        bars: { type: integer }
        fromTime: { type: integer, format: int64 }
        toTime: { type: integer, format: int64 }
        spec: { $ref: '#/components/schemas/BacktestSpec' }
        params: { type: object, additionalProperties: true }
        summary: { $ref: '#/components/schemas/BacktestSummary' }
        equityCurve:
          type: array
          items: { $ref: '#/components/schemas/EquityCurvePoint' }
        fills:
          type: array
          items: { $ref: '#/components/schemas/BacktestFill' }
        signals:
          type: array
          items: { $ref: '#/components/schemas/BacktestSignal' }
        warnings:
          type: array
          items: { type: string }
        createdAt: { type: string, format: date-time }

    # ── Copy trading ────────────────────────────────────────────
    CopySubscription:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenantId: { type: string }
        leaderAccountId: { type: string }
        followerAccountId: { type: string }
        relationshipKind: { type: string, enum: [self, social] }
        status: { type: string, enum: [pending, active, paused, cancelled] }
        allocationMode: { type: string, enum: [fixed_ratio, proportional_equity] }
        allocationValue: { type: number, nullable: true }
        stopLossPct: { type: number, nullable: true }
        feeBps: { type: integer }
        routingRuleId: { type: string, nullable: true }
        optInAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    CopySubscriptionsList:
      type: object
      properties:
        asFollower:
          type: array
          items: { $ref: '#/components/schemas/CopySubscription' }
        asLeader:
          type: array
          items: { $ref: '#/components/schemas/CopySubscription' }
    CreateCopySubscriptionRequest:
      type: object
      required: [followerAccountId, relationshipKind]
      properties:
        followerAccountId: { type: string, description: Account to copy INTO (must be owned by the caller) }
        relationshipKind: { type: string, enum: [self, social] }
        leaderAccountId: { type: string, description: "Leader account uuid ('self' requires this; 'social' accepts either id or number)" }
        leaderAccountNumber: { type: string, description: "Human-readable leader account number (PH-NNNNNN), 'social' only" }
        allocationMode: { type: string, enum: [fixed_ratio, proportional_equity] }
        allocationValue: { type: number, nullable: true, minimum: 0, maximum: 100 }
        stopLossPct: { type: number, nullable: true, minimum: 0, maximum: 100 }
        feeBps: { type: integer, minimum: 0, maximum: 10000 }
        activate: { type: boolean, description: When true the subscription goes live immediately; else lands 'pending' }
    UpdateCopySubscriptionRequest:
      type: object
      required: [action]
      properties:
        action: { type: string, enum: [pause, resume, update] }
        allocationMode: { type: string, enum: [fixed_ratio, proportional_equity] }
        allocationValue: { type: number, nullable: true, minimum: 0, maximum: 100 }
        stopLossPct: { type: number, nullable: true, minimum: 0, maximum: 100 }
    LeaderProfile:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenantId: { type: string }
        accountId: { type: string }
        isPublic: { type: boolean, description: The consent gate — true = copyable by strangers }
        displayName: { type: string, nullable: true }
        bio: { type: string, nullable: true }
        feeBps: { type: integer }
        minFollowerBalanceUsd: { type: number, nullable: true }
        maxFollowers: { type: integer, nullable: true }
        verifiedStatus: { type: string, enum: [none, pending, verified, rejected] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    UpsertLeaderProfileRequest:
      type: object
      required: [isPublic]
      properties:
        isPublic: { type: boolean, description: "true = make my active account copyable (the CONSENT action)" }
        displayName: { type: string, nullable: true, maxLength: 120 }
        bio: { type: string, nullable: true, maxLength: 2000 }
        feeBps: { type: integer, minimum: 0, maximum: 10000 }
        minFollowerBalanceUsd: { type: number, nullable: true, minimum: 0 }
        maxFollowers: { type: integer, nullable: true, minimum: 0 }
    LeaderboardEntry:
      type: object
      properties:
        accountNumber: { type: string }
        displayName: { type: string, nullable: true }
        bio: { type: string, nullable: true }
        verifiedStatus: { type: string, enum: [none, pending, verified, rejected] }
        feeBps: { type: integer }
        window: { type: string, enum: ['7d', '30d', all] }
        pnlUsd: { type: number, nullable: true }
        winRatePct: { type: number, nullable: true }
        volumeUsd: { type: number, nullable: true }
        tradeCount: { type: integer }
        maxDrawdownPct: { type: number, nullable: true }
        currentStreak: { type: integer }
        computedAt: { type: string, format: date-time, nullable: true }
    LeaderboardResponse:
      type: object
      properties:
        window: { type: string, enum: ['7d', '30d', all] }
        orderBy: { type: string, enum: [pnl, winRate, drawdown] }
        limit: { type: integer }
        leaders:
          type: array
          items: { $ref: '#/components/schemas/LeaderboardEntry' }
    TraderStatsRow:
      type: object
      properties:
        window: { type: string, enum: ['7d', '30d', all] }
        pnlUsd: { type: number, nullable: true }
        winRatePct: { type: number, nullable: true }
        volumeUsd: { type: number, nullable: true }
        tradeCount: { type: integer }
        maxDrawdownPct: { type: number, nullable: true }
        currentStreak: { type: integer }
        computedAt: { type: string, format: date-time, nullable: true }
    TraderProfile:
      type: object
      properties:
        accountNumber: { type: string }
        displayName: { type: string, nullable: true }
        bio: { type: string, nullable: true }
        verifiedStatus: { type: string, enum: [none, pending, verified, rejected] }
        feeBps: { type: integer }
        minFollowerBalanceUsd: { type: number, nullable: true }
        maxFollowers: { type: integer, nullable: true }
        activeFollowerCount: { type: integer }
        profileCreatedAt: { type: string, format: date-time, nullable: true }
        stats:
          type: array
          items: { $ref: '#/components/schemas/TraderStatsRow' }
    CopyEarningsRow:
      type: object
      properties:
        accrualId: { type: string }
        subscriptionId: { type: string }
        followerAccountNumber: { type: string, nullable: true }
        followerDisplayName: { type: string, nullable: true }
        periodId: { type: string }
        followerProfitUsd: { type: number }
        highWaterMarkUsd: { type: number }
        perfFeeBps: { type: integer }
        perfFeeUsd: { type: number }
        status: { type: string, enum: [accrued, settled, waived, void] }
        computedAt: { type: string, format: date-time, nullable: true }
        settledAt: { type: string, format: date-time, nullable: true }
    CopyEarnings:
      type: object
      properties:
        totals:
          type: object
          properties:
            accruedUsd: { type: number }
            settledUsd: { type: number }
            waivedUsd: { type: number }
            voidUsd: { type: number }
            rowCount: { type: integer }
        perFollower:
          type: array
          items: { $ref: '#/components/schemas/CopyEarningsRow' }
    CopyFeesRow:
      type: object
      properties:
        accrualId: { type: string }
        subscriptionId: { type: string }
        leaderAccountNumber: { type: string, nullable: true }
        leaderDisplayName: { type: string, nullable: true }
        periodId: { type: string }
        followerProfitUsd: { type: number }
        highWaterMarkUsd: { type: number }
        perfFeeBps: { type: integer }
        perfFeeUsd: { type: number }
        status: { type: string, enum: [accrued, settled, waived, void] }
        computedAt: { type: string, format: date-time, nullable: true }
        settledAt: { type: string, format: date-time, nullable: true }
    CopyFees:
      type: object
      properties:
        totals:
          type: object
          properties:
            owedUsd: { type: number }
            paidUsd: { type: number }
            waivedUsd: { type: number }
            rowCount: { type: integer }
        perSubscription:
          type: array
          items: { $ref: '#/components/schemas/CopyFeesRow' }

    # ============================================================
    # ONBOARDING
    # ============================================================

    TenantSignupRequest:
      type: object
      required: [email, slug, displayName]
      properties:
        email: { type: string, format: email, maxLength: 254 }
        slug:
          type: string
          minLength: 2
          maxLength: 40
          pattern: '^[a-z0-9][a-z0-9-]*$'
          description: Becomes `tenants.id`. Lowercased; a small set of platform slugs is reserved and rejected.
        displayName: { type: string, minLength: 1, maxLength: 80 }
        capabilityLayers:
          type: array
          minItems: 1
          maxItems: 3
          items: { type: string, enum: [L1_DEX, L2_LIVE_PROP, L3_PAPER_PROP] }
          description: Defaults to `[L1_DEX]` when omitted.
        payoutWalletAddress:
          type: string
          pattern: '^0x[a-fA-F0-9]{40}$'
          description: Optional. Revenue destination captured at signup.

    TenantSignupResponse:
      type: object
      required: [ok, signupId, slug, email, emailed, expiresAt]
      properties:
        ok: { type: boolean }
        signupId: { type: string }
        slug: { type: string }
        email: { type: string, format: email }
        emailed: { type: boolean, description: True only when the Resend provider confirmed delivery. }
        expiresAt: { type: string, format: date-time }
        verifyUrl:
          type: string
          description: Only present outside production (dev/test convenience). Never returned in production.

    TenantVerifyRequest:
      type: object
      required: [token]
      properties:
        token: { type: string, minLength: 16, maxLength: 128 }

    TenantVerifyResponse:
      type: object
      required: [tenant, adminInvite, operatorCredential]
      properties:
        tenant:
          type: object
          properties:
            id: { type: string }
            displayName: { type: string }
            vertical: { type: string, enum: [trading_platform, dex_aggregator, prop_firm, hybrid] }
            capabilityLayers:
              type: array
              items: { type: string, enum: [L1_DEX, L2_LIVE_PROP, L3_PAPER_PROP] }
            capitalMode: { type: string, enum: [live, paper, hybrid] }
        adminInvite:
          type: object
          properties:
            username: { type: string }
            email: { type: string, format: email, nullable: true }
            inviteUrl: { type: string }
        operatorCredential:
          type: object
          nullable: true
          description: >-
            Present once, only when `SELF_SERVE_FIRM_ISSUANCE_ENABLED` is set
            and the mint succeeded — `firm:operate` + `prop:manage`. The
            secret is never readable again after this response.
          properties:
            apiKey: { type: string }
            apiSecret: { type: string }

    # ============================================================
    # TENANT CONFIG
    # ============================================================

    TenantBrandingConfig:
      type: object
      description: >-
        Every field optional — unset fields keep their previously stored
        value on write. Asset URLs pass through verbatim (operator owns
        hosting); not proxied or transformed.
      properties:
        brandName: { type: string, minLength: 1, maxLength: 64 }
        logoUrl: { type: string, format: uri, maxLength: 2048, description: 'Logo for dark backdrops.' }
        logoDarkUrl: { type: string, format: uri, maxLength: 2048, description: 'Logo for light backdrops.' }
        faviconUrl: { type: string, format: uri, maxLength: 2048 }

    TenantThemeConfig:
      type: object
      description: >-
        Every field optional — unset fields keep their previously stored
        value on write. Colors accept any CSS color string (hex/rgb/oklch),
        1–64 chars.
      properties:
        bg0: { type: string, maxLength: 64 }
        panel: { type: string, maxLength: 64 }
        panel2: { type: string, maxLength: 64 }
        elev: { type: string, maxLength: 64 }
        elev2: { type: string, maxLength: 64 }
        border: { type: string, maxLength: 64 }
        border2: { type: string, maxLength: 64 }
        text: { type: string, maxLength: 64 }
        text2: { type: string, maxLength: 64 }
        text3: { type: string, maxLength: 64 }
        up: { type: string, maxLength: 64 }
        dn: { type: string, maxLength: 64 }
        amber: { type: string, maxLength: 64 }
        primary: { type: string, maxLength: 64 }
        radius:
          type: integer
          minimum: 0
          maximum: 24
          description: Px. The FE derives r-control / r-panel proportionally.
        fontSans: { type: string, maxLength: 240, description: CSS font-family stack. }
        fontMono: { type: string, maxLength: 240, description: CSS font-family stack. }
        presetId: { type: string, minLength: 1, maxLength: 64, description: tweakcn preset id. }
        lightOverrides:
          type: object
          additionalProperties: { type: string, maxLength: 64 }
          description: Partial tweakcn color-palette overrides (light mode).
        darkOverrides:
          type: object
          additionalProperties: { type: string, maxLength: 64 }
          description: Partial tweakcn color-palette overrides (dark mode).
        hueShift: { type: number, minimum: -180, maximum: 180 }
        satShift: { type: number, minimum: -50, maximum: 50 }
        lightnessShift: { type: number, minimum: -50, maximum: 50 }
        shadowOpacity:
          type: number
          minimum: 0
          maximum: 2
          description: 'Legacy: 0..2 multiplier. Studio-engaged (shadowColor set): 0..1 base alpha.'
        shadowColor: { type: string, maxLength: 64, description: Setting this engages the full shadow-studio generator. }
        shadowBlur: { type: number, minimum: 0, maximum: 64 }
        shadowSpread: { type: number, minimum: -32, maximum: 32 }
        shadowOffsetX: { type: number, minimum: -32, maximum: 32 }
        shadowOffsetY: { type: number, minimum: -32, maximum: 32 }
        letterSpacing: { type: number, minimum: -0.1, maximum: 0.2 }

    TenantFeatureFlags:
      type: object
      additionalProperties: { type: boolean }
      description: >-
        Flat `{ [flagKey]: boolean }` map. On write, regulated / money-moving
        compliance flags can never be flipped false→true over this endpoint.

    TenantVenues:
      type: object
      properties:
        primary: { type: string, nullable: true }
        enabled:
          type: array
          items: { type: string }

    TenantPlan:
      type: object
      properties:
        plan: { type: string, enum: [free, pro] }
        effectivePlan:
          type: string
          enum: [free, pro]
          description: Falls back to `free` while the subscription isn't actively paying.
        status: { type: string }
        entitlements:
          type: object
          additionalProperties: true
          properties:
            customDomain: { type: boolean }
            footerRemoval: { type: boolean }
            apiTier: { type: string, enum: [basic, pro] }
            maxSeats: { type: integer }
            dexShareBps: { type: integer }
            perAccountDiscountBps: { type: integer }
        currentPeriodEnd: { type: string, format: date-time, nullable: true }

    TenantConfigAggregate:
      type: object
      properties:
        id: { type: string }
        displayName: { type: string }
        vertical: { type: string, enum: [trading_platform, dex_aggregator, prop_firm, hybrid] }
        capabilityLayers:
          type: array
          items: { type: string, enum: [L1_DEX, L2_LIVE_PROP, L3_PAPER_PROP] }
        capitalMode: { type: string, enum: [live, paper, hybrid] }
        payoutWalletAddress: { type: string, nullable: true }
        branding: { $ref: '#/components/schemas/TenantBrandingConfig', nullable: true }
        theme: { $ref: '#/components/schemas/TenantThemeConfig', nullable: true }
        flags: { $ref: '#/components/schemas/TenantFeatureFlags' }
        venues: { $ref: '#/components/schemas/TenantVenues' }
        plan: { $ref: '#/components/schemas/TenantPlan' }

    # ============================================================
    # META
    # ============================================================

    DiscoveryDocument:
      type: object
      properties:
        version: { type: string, example: v1 }
        auth:
          type: object
          properties:
            schemes: { type: array, items: { type: string }, example: [bearer] }
            mintUrl: { type: string, example: /api/auth/api-token }
            revokeUrl: { type: string, example: /api/auth/api-token/revoke }
            tokenLifetimeSeconds: { type: integer, example: 86400 }
        endpoints:
          type: array
          items:
            type: object
            properties:
              path: { type: string }
              methods: { type: array, items: { type: string } }
              summary: { type: string }
              tier: { type: string, enum: [readonly, liquidation, fullTrading] }
        streams:
          type: array
          items:
            type: object
            properties:
              path: { type: string }
              summary: { type: string }
              authVia: { type: string }
        notes:
          type: array
          items: { type: string }

    # ============================================================
    # PROP DEPOSIT / TRANSPARENCY
    # ============================================================

    PropDepositCalldataRequest:
      type: object
      required: [propAccountId, amountUsdcUnits]
      properties:
        propAccountId: { type: string, format: uuid }
        amountUsdcUnits: { type: string, pattern: '^\d+$', description: Serialized bigint, USDC 6-decimal units. }
        includeApprove:
          type: boolean
          description: >-
            Opt out of the ERC-20 approve call when cached allowance already
            covers the amount. Defaults true (a first-time depositor needs it).

    PropDepositCalldataResponse:
      type: object
      properties:
        to: { type: string, description: PropInstance contract address. }
        data: { type: string, description: 'Legacy single-call field: the deposit call only.' }
        chainId: { type: integer, nullable: true }
        value: { type: string, example: '0' }
        propAccountId: { type: string }
        usdcAddress: { type: string, nullable: true, description: Null when unresolvable for the tenant's chain. }
        spender: { type: string, description: Same as `to` — the instance address. }
        amountUsdcUnits: { type: string }
        calls:
          type: array
          description: Full ordered call batch — `[approve?, deposit]` when usdcAddress resolves, else just `[deposit]`.
          items:
            type: object
            properties:
              to: { type: string }
              data: { type: string }
              value: { type: string }

    FirmTransparency:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        status: { type: string }
        chainId: { type: integer, nullable: true }
        instanceAddress: { type: string, nullable: true }
        multisigAddress: { type: string, nullable: true }
        deployedAt: { type: string, format: date-time, nullable: true }
        explorer:
          type: object
          properties:
            instance: { type: string, nullable: true }
            multisig: { type: string, nullable: true }
        onChain:
          type: object
          nullable: true
          description: Null when the instance isn't deployed / unreachable.
          properties:
            treasuryUsd: { type: string }
            collateralUsd: { type: string }
            availableUsd: { type: string }
        solvency:
          type: string
          description: Solvency classification derived from the on-chain treasury vs. ledger expectation.
        ledger:
          type: object
          properties:
            payInsUsd: { type: string }
            payoutsUsd: { type: string }
            forfeitsUsd: { type: string }
        reconcile:
          type: object
          additionalProperties: true
          description: >-
            Money-reconcile bundle comparing ledger expectation to on-chain
            reality. `{ status: 'not_deployed' }` when no instance is live.
