asyncapi: 2.6.0
info:
  title: TRON Charts WebSocket API
  version: 1.0.0
  description: |
    Real-time WSS API for the Tron Charts trading platform. Covers two
    services:

    - **Risk Engine WSS** (`/ws/risk`) — account state, positions, risk,
      order-intent dispatch, fills, prop-account telemetry. The primary
      programmatic surface for Provider API integrators.

    - **Quote Service WSS** (`/ws/quotes`) — multi-venue quote stream
      (mark / mid / book-top per symbol). Lower-tier auth: any
      authenticated session.

    Plus **outbound webhooks** — server-initiated HTTP POSTs to
    subscriber URLs for account / position / subscription / trade
    events. Documented in this same file for one-stop reference.

    ## Execution model — routed vs. internalised

    Every frame and webhook that reports an execution comes from one of
    two paths, and the `venue` field says which:

    - **Routed (A-book)** — the order was signed and dispatched to the
      named exchange. `venue` is that exchange and `venueOrderId` is the
      exchange's own id.
    - **Internalised (B-book)** — the firm is the counterparty. Our own
      engine matched the order against a firm-canonical mark, and **no
      order was placed at any exchange**. The firm hedges its aggregate
      exposure separately, on its own account; that hedge never appears
      on these streams.

    Internalised rows carry `venue: "paper"`. The sentinel means "matched
    by our engine", not "simulated money" — it covers both the sandbox and
    internalised real-money accounts, which settle real PnL. Which mode
    applies is a property of the **account**, not of the venue string, so
    discriminate on the account if you need to tell the two apart.

    `venueOrderId` on an internalised row is an id WE minted for the
    internal match — stable, safe as a dedup key, but it identifies no
    exchange order because none exists. It is nullable everywhere it
    appears.

    ## Wire encoding (msgpack opt-in)

    All three server-push WSS surfaces — `/ws/risk`, `/ws/quotes`, and the
    browser-internal market-data stream `/ws/market` (depth / tape / candle;
    not separately documented as a channel here) — support an opt-in binary
    encoding: append `?encoding=msgpack` to the upgrade URL to receive
    server→client frames as MessagePack instead of JSON text. JSON is the
    default, so existing clients and the bundled TypeScript SDK are unaffected;
    a per-surface operator kill-switch
    can force JSON regardless of the query param. The decoded object is byte-for-byte
    equivalent across both encodings — msgpack is purely a smaller, faster
    transport. Client→server frames remain JSON on `/ws/risk` and `/ws/quotes`.

    ## Authentication

    1. Mint a single-use WSS token via `POST /api/auth/bootstrap`
       (returns one token per WSS surface).
    2. Open the WebSocket connection.
    3. Send `Authenticate` frame as the FIRST message:
       `{ "type": "Authenticate", "token": "<wss-token>" }`
    4. Receive `Authenticated` frame on success or `Result { ok: false }`
       on failure.

    Cookie-rooted browser sessions (FE) auto-authenticate via the
    cookie; the explicit `Authenticate` frame is optional but
    recommended for consistency.

    ## Fanout scope (which accounts a socket receives)

    A socket only receives frames for the accounts in its scope:

    | Credential | Scope |
    |---|---|
    | apiClient (bootstrap token) | the ONE account the credential is bound to. `Subscribe.accountId` re-scopes to another account the credential OWNS; the socket carries one active account at a time. |
    | Cookie / SIWE session (FE) | every account the session user OWNS. |

    A socket never receives another tenant's frames, and a credential whose
    access is revoked mid-connection has its socket torn down rather than left
    alive until the next reconnect.

    ## Heartbeat

    Send `Ping { type: "Ping" }` every 30s. Server replies with
    `Pong { type: "Pong", serverTime }`. Bun's idle timeout closes
    the socket after 60s of inbound silence; the Ping resets it.

    Alternative: `Alive { type: "Alive" }` resets the timeout
    without a structured reply (legacy; new code uses Ping/Pong).

    ## Tier enforcement (per frame)

    Risk Engine WSS enforces the credential tier on every C→S frame:

    | Frame | Tier required |
    |---|---|
    | `Authenticate`, `Alive`, `Ping`, `Subscribe`, `Get-*` | readonly+ |
    | `Cancel-Order-Intent` | liquidation+ |
    | `Order-Sign-Result`, `Replace-Order-Intent`, `Bracket-*`, `Position-Close` | fullTrading |

    Reject frames send `Result { ok: false, error: "forbidden", detail }`.

    ## Outbound webhook posture

    POST to subscriber URL with body:
    ```json
    {
      "eventId": "uuid-v4",
      "category": "trade.closed",
      "tenantId": "acme",
      "ts": "2026-05-24T18:30:00Z",
      "payload": { ... }
    }
    ```
    Headers:
    ```
    x-api-key: <subscriber-supplied secret>
    x-webhook-event-id: <eventId>
    x-webhook-event-category: <category>
    x-webhook-attempt: <1-based attempt counter>
    x-webhook-request-id: <uuid-v4 per attempt>
    ```
    Receivers must return HTTP 200 on success. Non-2xx triggers
    exponential backoff retry (1s, 4s, 30s, 5m, 30m, 2h, 6h, 24h).
    After `maxAttempts` exhaustion the subscription auto-disables.

  contact:
    name: Tron Charts API support
    email: hello@troncharts.xyz

servers:
  production:
    url: wss://api.troncharts.xyz
    protocol: wss
    description: Production WSS endpoint
  staging:
    url: wss://api.staging.troncharts.xyz
    protocol: wss
    description: Staging

channels:
  /ws/risk:
    description: |
      Risk Engine WSS — primary trading + state surface. JSON frames
      over a single bidirectional connection.

      Wire encoding (opt-in): server→client frames are JSON by default. A
      client may request binary **msgpack** frames by adding `?encoding=msgpack`
      to the upgrade URL — smaller + faster to parse on a chatty connection.
      The `seq` stamping + every payload shape are identical across both
      formats, so a msgpack consumer decodes each binary frame with any
      msgpack library and reads the same fields. Client→server frames stay JSON
      regardless. Operators can force JSON for everyone. (The same `?encoding=`
      negotiation is offered on `/ws/market` and `/ws/quotes`.)

      Sequence numbers (gap detection): every server→client frame that
      leaves the per-socket outbox carries a monotonic integer `seq`
      (1, 2, 3, …) for the lifetime of the connection. A consumer
      rebuilding order/risk/position state asserts contiguity
      (`seq == lastSeq + 1`); a gap means one or more frames were
      dropped or coalesced away, and the client should resync (re-send
      the relevant `Get-*` snapshot request). Control frames sent
      outside the outbox — `Pong` and the `Authenticated` handshake —
      are intentionally unsequenced; check contiguity only over frames
      that carry `seq`. A new connection restarts the `seq` lane at 1.
    bindings:
      ws:
        method: GET
    subscribe:
      summary: Frames the server pushes to the client
      message:
        oneOf:
          - $ref: '#/components/messages/AuthenticatedFrame'
          - $ref: '#/components/messages/ResultFrame'
          - $ref: '#/components/messages/PongFrame'
          - $ref: '#/components/messages/AccountStateUpdate'
          - $ref: '#/components/messages/BalanceUpdate'
          - $ref: '#/components/messages/MarginUpdate'
          - $ref: '#/components/messages/RiskUpdate'
          - $ref: '#/components/messages/PositionUpdate'
          - $ref: '#/components/messages/PositionRemoved'
          - $ref: '#/components/messages/PositionSnapshot'
          - $ref: '#/components/messages/OpenOrdersUpdate'
          - $ref: '#/components/messages/OrderIntent'
          - $ref: '#/components/messages/OrderIntentUpdate'
          - $ref: '#/components/messages/AccountEvent'
          - $ref: '#/components/messages/PropAccountUpdate'
          - $ref: '#/components/messages/BracketInfo'
          - $ref: '#/components/messages/LogMsg'
          - $ref: '#/components/messages/LoggedOff'
          - $ref: '#/components/messages/IpUpdate'
          - $ref: '#/components/messages/ResyncRequiredFrame'
    publish:
      summary: Frames the client sends to the server
      message:
        oneOf:
          - $ref: '#/components/messages/AuthenticateFrame'
          - $ref: '#/components/messages/AliveFrame'
          - $ref: '#/components/messages/PingFrame'
          - $ref: '#/components/messages/ResumeFrame'
          - $ref: '#/components/messages/SubscribeFrame'
          - $ref: '#/components/messages/UnsubscribeFrame'
          - $ref: '#/components/messages/GetMarginFrame'
          - $ref: '#/components/messages/GetBalanceFrame'
          - $ref: '#/components/messages/GetRiskUpdateFrame'
          - $ref: '#/components/messages/GetPositionUpdateFrame'
          - $ref: '#/components/messages/GetOpenOrdersFrame'
          - $ref: '#/components/messages/RequestTradeHistoryFrame'
          - $ref: '#/components/messages/OrderSignResultFrame'
          - $ref: '#/components/messages/CancelOrderIntentFrame'
          - $ref: '#/components/messages/ReplaceOrderIntentFrame'
          - $ref: '#/components/messages/BracketInsertFrame'
          - $ref: '#/components/messages/BracketModifyFrame'
          - $ref: '#/components/messages/BracketCancelFrame'
          - $ref: '#/components/messages/PositionCloseFrame'

  /ws/quotes:
    description: |
      Quote Service WSS — multi-venue mark / mid / book-top per symbol.
      Subscribe-only; clients pick the venue:symbol pairs they want.

      **Wire encoding (opt-in):** append `?encoding=msgpack` to the upgrade URL
      to receive server→client frames as MessagePack binary instead of JSON
      text. The default is JSON, so existing clients are unaffected; any value
      other than `msgpack` (or the operator kill-switch) keeps the connection
      on JSON. The decoded
      object is identical under both encodings — msgpack is purely a smaller,
      faster-to-parse transport for this high-rate mark feed. Client→server
      frames stay JSON regardless (the server has no msgpack inbound decoder on
      this surface). The bundled TypeScript SDK uses JSON only.
    subscribe:
      summary: Quote tick stream
      message:
        $ref: '#/components/messages/QuoteTick'

  webhooks:
    description: |
      Outbound webhook delivery — server POSTs JSON to subscriber URLs.
      Documented as an AsyncAPI channel for clarity; this is HTTP, not
      WSS. Use this section to design your receiver.
    subscribe:
      summary: Receive a webhook from Tron Charts
      message:
        oneOf:
          - $ref: '#/components/messages/AccountCreatedWebhook'
          - $ref: '#/components/messages/AccountBalanceChangedWebhook'
          - $ref: '#/components/messages/AccountStatusChangedWebhook'
          - $ref: '#/components/messages/PositionOvernightWebhook'
          - $ref: '#/components/messages/SubscriptionCreatedWebhook'
          - $ref: '#/components/messages/SubscriptionUpdatedWebhook'
          - $ref: '#/components/messages/TradeClosedWebhook'

components:
  messages:
    # ===== C → S frames =====

    AuthenticateFrame:
      name: Authenticate
      title: Authenticate session
      payload:
        type: object
        required: [type]
        properties:
          type: { type: string, const: Authenticate }
          id: { type: string, description: 'Optional correlation id' }
          token: { type: string, description: 'WSS token from /api/auth/bootstrap; omit for cookie-rooted sessions' }

    AliveFrame:
      name: Alive
      title: Legacy heartbeat (use Ping instead)
      payload:
        type: object
        required: [type]
        properties:
          type: { type: string, const: Alive }
          id: { type: string }

    PingFrame:
      name: Ping
      title: Heartbeat — send every 30s
      payload:
        type: object
        required: [type]
        properties:
          type: { type: string, const: Ping }
          id: { type: string }

    ResumeFrame:
      name: Resume
      title: Request replay of frames after lastSeq
      summary: |
        Phase 0 gap recovery. On detecting a `seq` gap, send the last
        contiguous seq; the server replays buffered frames after it
        (with their original seq), or replies Resync-Required if the gap
        exceeds the ring buffer.
      payload:
        type: object
        required: [type, lastSeq]
        properties:
          type: { type: string, const: Resume }
          id: { type: string }
          lastSeq: { type: integer, minimum: 0, description: 'Last seq the client received contiguously.' }

    SubscribeFrame:
      name: Subscribe
      payload:
        type: object
        required: [type, topics]
        properties:
          type: { type: string, const: Subscribe }
          id: { type: string }
          topics:
            type: array
            items:
              type: string
              description: 'See TOPICS enum: pos:* / orders:* / risk / margin / balance / etc.'
          accountId:
            type: string
            description: >
              Optional. Rescope this socket to a specific account you OWN (e.g.
              an api-client selecting a child account it provisioned). Snapshot +
              live frames then stream for that account. Rejected with
              `account_forbidden` if it isn't owned by the socket's
              authenticated account. The socket carries one active account —
              re-Subscribe with a different accountId to switch.

    UnsubscribeFrame:
      name: Unsubscribe
      payload:
        type: object
        required: [type, topics]
        properties:
          type: { type: string, const: Unsubscribe }
          id: { type: string }
          topics: { type: array, items: { type: string } }

    GetMarginFrame:
      name: Get-Margin
      payload:
        type: object
        required: [type]
        properties:
          type: { type: string, const: Get-Margin }
          id: { type: string }

    GetBalanceFrame:
      name: Get-Balance
      payload:
        type: object
        required: [type]
        properties:
          type: { type: string, const: Get-Balance }
          id: { type: string }

    GetRiskUpdateFrame:
      name: Get-Risk-Update
      payload:
        type: object
        required: [type]
        properties:
          type: { type: string, const: Get-Risk-Update }
          id: { type: string }

    GetPositionUpdateFrame:
      name: Get-Position-Update
      payload:
        type: object
        required: [type]
        properties:
          type: { type: string, const: Get-Position-Update }
          id: { type: string }

    GetOpenOrdersFrame:
      name: Get-open-orders
      payload:
        type: object
        required: [type]
        properties:
          type: { type: string, const: Get-open-orders }
          id: { type: string }

    RequestTradeHistoryFrame:
      name: Request-Trade-History
      payload:
        type: object
        required: [type]
        properties:
          type: { type: string, const: Request-Trade-History }
          id: { type: string }
          from: { type: string, format: date-time }
          to: { type: string, format: date-time }
          limit: { type: integer, minimum: 1, maximum: 500 }

    OrderSignResultFrame:
      name: Order-Sign-Result
      title: Client returns the signed action after server requests a signature
      description: |
        Reply to an `Order-Intent` push. Browser FE owns the agent
        wallet's private key; signs locally + replies here. BE-side
        auto-sign credentials (Provider API) skip this entirely —
        the BE signs in-process at compose time.
      payload:
        type: object
        required: [type, intentId]
        properties:
          type: { type: string, const: Order-Sign-Result }
          id: { type: string }
          intentId: { type: string }
          signedAction: { type: object, description: 'Venue-specific signed payload' }
          rejected: { type: boolean, description: 'true when the FE cancelled the sign request' }

    CancelOrderIntentFrame:
      name: Cancel-Order-Intent
      payload:
        type: object
        required: [type, intentId]
        properties:
          type: { type: string, const: Cancel-Order-Intent }
          id: { type: string }
          intentId: { type: string }

    ReplaceOrderIntentFrame:
      name: Replace-Order-Intent
      payload:
        type: object
        required: [type, intentId]
        properties:
          type: { type: string, const: Replace-Order-Intent }
          id: { type: string }
          intentId: { type: string }
          newPrice: { type: number }
          newQty: { type: number }
          newTriggerPrice: { type: number }
          newStopLimitPrice: { type: number }

    BracketInsertFrame:
      name: Bracket-Insert
      title: Place a bracket order — entry + optional TP + optional SL
      payload:
        type: object
        required: [type, parentIntent]
        properties:
          type: { type: string, const: Bracket-Insert }
          id: { type: string }
          parentIntent:
            type: object
            required: [venue, accountId, symbol, side, qty, type]
            properties:
              venue: { type: string, enum: [hyperliquid, aster, polymarket] }
              accountId: { type: string }
              symbol: { type: string }
              side: { type: string, enum: [buy, sell] }
              qty: { type: number }
              type: { type: string, enum: [market, limit] }
              price: { type: number }
              reduceOnly: { type: boolean }
          takeProfit:
            type: object
            properties:
              triggerPrice: { type: number }
              stopLimitPrice: { type: number }
          stopLoss:
            type: object
            properties:
              triggerPrice: { type: number }
              stopLimitPrice: { type: number }

    BracketModifyFrame:
      name: Bracket-Modify
      payload:
        type: object
        required: [type, parentIntentId]
        properties:
          type: { type: string, const: Bracket-Modify }
          id: { type: string }
          parentIntentId: { type: string }
          takeProfit:
            type: object
            properties:
              triggerPrice: { type: number }
              stopLimitPrice: { type: number }
          stopLoss:
            type: object
            properties:
              triggerPrice: { type: number }
              stopLimitPrice: { type: number }

    BracketCancelFrame:
      name: Bracket-Cancel
      payload:
        type: object
        required: [type, parentIntentId]
        properties:
          type: { type: string, const: Bracket-Cancel }
          id: { type: string }
          parentIntentId: { type: string }

    PositionCloseFrame:
      name: Position-Close
      payload:
        type: object
        required: [type, accountId, venue, symbol]
        properties:
          type: { type: string, const: Position-Close }
          id: { type: string }
          accountId: { type: string }
          venue: { type: string, enum: [hyperliquid, aster, polymarket] }
          symbol: { type: string }
          qty: { type: number, description: 'Optional partial-close amount; null = close all' }

    # ===== S → C frames =====

    AuthenticatedFrame:
      name: Authenticated
      title: Server confirms authentication
      payload:
        type: object
        required: [type, ok, scope]
        properties:
          type: { type: string, const: Authenticated }
          id: { type: string }
          ok: { type: boolean, const: true }
          scope:
            type: object
            properties:
              sessionId: { type: string }
              accountId: { type: string, nullable: true }
              walletAddress: { type: string, nullable: true }
              isAdmin: { type: boolean }

    ResultFrame:
      name: Result
      title: Generic outcome of a client request
      payload:
        type: object
        required: [type, ok]
        properties:
          type: { type: string, const: Result }
          id: { type: string }
          ok: { type: boolean }
          error: { type: string }
          detail: { type: string }

    ResyncRequiredFrame:
      name: Resync-Required
      title: Client must re-snapshot
      summary: |
        The server could not replay the requested range (the gap exceeded
        the outbox ring buffer) or dropped frames on overflow. Re-Subscribe
        and re-request snapshots (Get-Position-Update / Get-open-orders).
      payload:
        type: object
        required: [type, reason]
        properties:
          type: { type: string, const: Resync-Required }
          reason: { type: string, enum: [gap_exceeds_buffer, outbox_overflow] }

    PongFrame:
      name: Pong
      payload:
        type: object
        required: [type, serverTime]
        properties:
          type: { type: string, const: Pong }
          id: { type: string }
          serverTime: { type: string, format: date-time }

    AccountStateUpdate:
      name: Account-State-Update
      title: Snapshot of balance + margin + equity per (account, venue)
      payload:
        type: object
        properties:
          type: { type: string, const: Account-State-Update }
          accountId: { type: string }
          venue: { type: string, description: "Routing key. Never the market-data provider name — futures surface the exchange (CME/CBOT/NYMEX/COMEX, or the 'Futures' bucket). For paper futures this is 'paper'. 'paper' also carries the internalised (B-booked) book — real money settled against the firm as counterparty, no exchange holding it; see 'Execution model'." }
          exchange: { type: string, description: "Human exchange name for display; futures with no instrument resolve to the 'Futures' bucket. The internal routing venue (data provider) is never surfaced." }
          balance: { type: string }
          equity: { type: string }
          marginUsed: { type: string }
          marginRatio: { type: number }
          freeMargin: { type: string }
          updatedAt: { type: string, format: date-time }

    BalanceUpdate:
      name: Balance-Update
      payload:
        type: object
        properties:
          type: { type: string, const: Balance-Update }
          balance: { type: string }

    MarginUpdate:
      name: Margin-Update
      title: Initial + maintenance margin per account — per position and per venue
      payload:
        type: object
        properties:
          type: { type: string, const: Margin-Update }
          accountId: { type: string, nullable: true }
          positions:
            type: array
            items:
              type: object
              properties:
                venue: { type: string }
                exchange: { type: string, description: "Human exchange name for display." }
                symbol: { type: string }
                side: { type: string, enum: [long, short] }
                qty: { type: string }
                notionalUsd: { type: number }
                initialMarginUsd: { type: number }
                maintenanceMarginUsd: { type: number }
          totalInitialMarginUsd: { type: number }
          totalMaintenanceMarginUsd: { type: number }
          byVenue:
            type: array
            description: "Per-venue IM/MM segregation (sums to the totals) — each venue is its own margin domain."
            items:
              type: object
              properties:
                venue: { type: string }
                exchange: { type: string }
                initialMarginUsd: { type: number }
                maintenanceMarginUsd: { type: number }
          computedAt: { type: string, format: date-time }

    RiskUpdate:
      name: Risk-Update
      title: Computed risk envelope per account
      payload:
        type: object
        properties:
          type: { type: string, const: Risk-Update }
          accountId: { type: string, nullable: true }
          dailyRealizedPnlUsd: { type: number }
          grossNotionalUsd: { type: number }
          topConcentrationPct: { type: number }
          topConcentrationSymbol: { type: string, nullable: true }
          byVenue:
            type: array
            description: "Per-venue realized-PnL + gross-notional segregation (sums to the totals)."
            items:
              type: object
              properties:
                venue: { type: string }
                exchange: { type: string }
                dailyRealizedPnlUsd: { type: number }
                grossNotionalUsd: { type: number }
          breaches:
            type: array
            items:
              type: object
              properties:
                code: { type: string, enum: [daily_loss_cap_exceeded, concentration_cap_exceeded] }
                detail: { type: string }

    PositionUpdate:
      name: Position-Update
      payload:
        type: object
        properties:
          type: { type: string, const: Position-Update }
          venue: { type: string, description: "Routing key. Never the market-data provider name — futures surface the exchange (CME/CBOT/NYMEX/COMEX, or the 'Futures' bucket). For paper futures this is 'paper'. 'paper' also marks a position opened by an internalised (B-booked) fill — matched by our engine with the firm as counterparty, no exchange order behind it; see 'Execution model'." }
          exchange: { type: string, description: "Human exchange name for display (CME/CBOT/NYMEX/COMEX for futures, else == venue). Resolved from the symbol, so correct even for paper-routed futures whose venue is 'paper'." }
          symbol: { type: string }
          side: { type: string, enum: [long, short] }
          qty: { type: string }
          entryPrice: { type: string }
          markPrice: { type: string, nullable: true }
          markPriceSource: { type: string, enum: [fe_reported, be_canonical, null] }
          liquidationPrice: { type: string, nullable: true }
          leverage: { type: number, nullable: true }
          openedAt: { type: string, format: date-time }
          updatedAt: { type: string, format: date-time }
          oseq: { type: integer, description: "OMS Phase 2 — process-monotonic OMS ordering stamp, drawn from ONE shared counter across every Position-Update / Position-Removed / Open-Orders-Update / order-terminal frame. Lets a consumer keyed-full-replace its book and drop a re-mark whose `oseq` is below the last removal it applied for the same (venue, symbol). Optional (current clients ignore it). DISTINCT from the envelope-level transport `seq` (per-socket gap-detection); `oseq` rides its own field so the outbox stamping never clobbers it. Resets to 1 on process restart — clients treat a lower `oseq` after reconnect as a fresh full snapshot baseline (a fresh Position-Snapshot / Open-Orders-Update is sent on (re)connect)." }

    PositionRemoved:
      name: Position-Removed
      payload:
        type: object
        properties:
          type: { type: string, const: Position-Removed }
          venue: { type: string, description: "Routing key. Never the market-data provider name — futures surface the exchange (CME/CBOT/NYMEX/COMEX, or the 'Futures' bucket). For paper futures this is 'paper'. 'paper' also covers internalised (B-booked) positions; see 'Execution model'." }
          symbol: { type: string }
          removedAt: { type: string, format: date-time }
          oseq: { type: integer, description: "OMS Phase 2 — OMS ordering stamp (see Position-Update.oseq). A removal always carries a higher `oseq` than any update that preceded it for the same (venue, symbol)." }
          terminal: { type: boolean, enum: [true], description: "OMS Phase 2 — explicit terminal tombstone marker; always true on a Position-Removed. The consumer records a tombstone keyed on (venue, symbol) at this `oseq` and refuses to re-add the position from any later snapshot whose `oseq` is below it. Optional." }

    PositionSnapshot:
      name: Position-Snapshot
      title: Full open-position set for an account (snapshot-on-connect / Get-Position-Update reply)
      summary: >-
        Sent once on connect and in reply to Get-Position-Update. Carries EVERY
        open position for the account in a single frame. Incremental Position-Update
        frames only fire when a position changes (fill, re-mark, close) — positions
        already open at connect time arrive ONLY here. A consumer that builds state
        from Position-Update alone will miss simultaneously-open positions until they
        next re-mark; always ingest this frame's `positions[]` on receipt.
      payload:
        type: object
        properties:
          type: { type: string, const: Position-Snapshot }
          walletAddress: { type: string, description: "Empty for account-scoped snapshots; per-row walletAddress disambiguates." }
          accountId: { type: string, nullable: true }
          subscriptionId: { type: string, nullable: true }
          computedAt: { type: string, format: date-time }
          oseq: { type: integer, description: "OMS Phase 2 — snapshot ordering baseline (see Position-Update.oseq). The full-position set seeded on connect; subsequent incremental Position-Update / Position-Removed frames carry a higher `oseq`. Optional." }
          positions:
            type: array
            items:
              type: object
              properties:
                walletAddress: { type: string }
                accountId: { type: string, nullable: true }
                venue: { type: string, description: "Routing key. See Position-Update.venue." }
                exchange: { type: string, description: 'Human exchange name for display; see Position-Update.exchange.' }
                symbol: { type: string }
                side: { type: string, enum: [long, short] }
                qty: { type: string }
                entryPrice: { type: string }
                markPrice: { type: string, nullable: true }
                markPriceSource: { type: string, enum: [fe_reported, be_canonical, null] }
                liquidationPrice: { type: string, nullable: true }
                liquidationPriceSource: { type: string, nullable: true }
                leverage: { type: number, nullable: true }
                openedAt: { type: string, format: date-time }
                updatedAt: { type: string, format: date-time }

    OpenOrdersUpdate:
      name: Open-Orders-Update
      payload:
        type: object
        properties:
          type: { type: string, const: Open-Orders-Update }
          accountId: { type: string }
          oseq: { type: integer, description: "OMS Phase 2 — snapshot ordering stamp drawn at materialization time so a later-built snapshot always out-ranks an older/coalesced one. A consumer keeps only the highest-`oseq` snapshot's `orders[]` (keyed full-replace) and ignores any snapshot whose `oseq` is below the newest it applied — this is how a stale/coalesced Open-Orders-Update can never re-insert a locally-removed order. Optional. DISTINCT from the envelope transport `seq` (see Position-Update.oseq)." }
          orders:
            type: array
            items:
              type: object
              properties:
                intentId: { type: string }
                venueOrderId: { type: string, nullable: true, description: "The exchange's own order id on a routed order; null until dispatch. On an internalised (B-booked) order — `venue` 'paper' — it is an id we minted for the internal match and identifies no exchange order, because none was placed. See 'Execution model'." }
                exchange: { type: string, description: 'Human exchange name for display; see Position-Update.exchange.' }
                symbol: { type: string }
                side: { type: string }
                type: { type: string }
                qty: { type: string }
                price: { type: string, nullable: true }
                state: { type: string }
                clientOrderId: { type: string, nullable: true, description: 'PERF-P4 — opaque FE-minted correlation id, echoed back so the client can adopt its optimistic working row by id (replace the temp id with the real one) instead of inserting a duplicate. Null for FE-snapshot / venue-poll-discovered rows.' }

    OrderIntent:
      name: Order-Intent
      title: Server requests a signature on a composed action (FE-signing path)
      payload:
        type: object
        properties:
          type: { type: string, const: Order-Intent }
          intentId: { type: string }
          venue: { type: string, description: "Routing key. Never the market-data provider name — futures surface the exchange (CME/CBOT/NYMEX/COMEX, or the 'Futures' bucket). For paper futures this is 'paper'. A 'paper' intent is matched by our engine and never dispatched to an exchange — that covers the sandbox AND internalised (B-booked) real-money accounts; see 'Execution model'." }
          exchange: { type: string, description: 'Human exchange name for display; see Position-Update.exchange.' }
          actionToSign: { type: object }
          dispatchEnvelope: { type: object }
          validForMs: { type: integer }

    OrderIntentUpdate:
      name: Order-Intent-Update
      title: Lifecycle transition for an order intent
      payload:
        type: object
        properties:
          type: { type: string, const: Order-Intent-Update }
          intentId: { type: string }
          state:
            type: string
            enum:
              - intent_pending
              - signed_pending_dispatch
              - dispatched
              - acknowledged
              - partially_filled
              - filled
              - canceled
              - rejected
              - replaced
              - expired
          venueOrderId: { type: string, nullable: true, description: "The exchange's own order id on a routed order; null until dispatch, and null if the intent terminated before it. On an internalised (B-booked) order it is an id we minted for the internal match — safe as a dedup key, but it resolves at no exchange because none was involved. See 'Execution model'." }
          clientOrderId: { type: string, nullable: true, description: 'PERF-P4 — opaque FE-minted correlation id, so the FE can drive its optimistic working row (fill / reject) by id even before the intent id or venue id is bound locally. Null when none was supplied.' }
          updatedAt: { type: string, format: date-time }
          oseq: { type: integer, description: "OMS Phase 2 — OMS ordering stamp for this transition (see Position-Update.oseq). Optional." }
          terminal: { type: boolean, description: "OMS Phase 2 — explicit terminal tombstone marker; true when `state` is a terminal OMS state (filled / canceled / rejected / replaced / expired). The consumer records a tombstone keyed on `intentId` (+ `venueOrderId`) at this `oseq` and refuses to re-add the order from any later stale Open-Orders snapshot. Absent on non-terminal transitions. Paper cancel/modify/fill carry it too. Optional." }

    AccountEvent:
      name: Account-Event
      payload:
        type: object
        properties:
          type: { type: string, const: Account-Event }
          kind: { type: string, description: 'fill / cancel / liquidation / etc' }
          payload: { type: object }

    PropAccountUpdate:
      name: Prop-Account-Update
      title: Foxify prop engine — challenge / payout / rule results
      payload:
        type: object
        properties:
          type: { type: string, const: Prop-Account-Update }
          propAccountId: { type: string }
          status: { type: string }
          ruleResults: { type: array, items: { type: object } }
          enforcementState:
            type: object
            nullable: true
            description: >-
              Active non-terminal enforcement posture set when a rule with a
              graduated breach action trips. Null when none. The account is
              still 'active'; the posture limits new opening orders.
            properties:
              action: { type: string, enum: [reduce_only, soft_stop_day, lock_cycle] }
              ruleCode: { type: string }
              detail: { type: string }
              since: { type: string, format: date-time }
              dayKey: { type: string, description: 'prop-day this applies to (soft_stop_day only)' }

    BracketInfo:
      name: Bracket-Info
      payload:
        type: object
        properties:
          type: { type: string, const: Bracket-Info }
          parentIntentId: { type: string }
          state: { type: string, enum: [active, tp_filled, sl_filled, canceled] }
          takeProfit:
            type: object
            nullable: true
            properties:
              intentId: { type: string, nullable: true }
              triggerPrice: { type: string, nullable: true }
              status: { type: string, enum: [open, filled, canceled, null] }
          stopLoss:
            type: object
            nullable: true
            properties:
              intentId: { type: string, nullable: true }
              triggerPrice: { type: string, nullable: true }
              status: { type: string, enum: [open, filled, canceled, null] }
          updatedAt: { type: string, format: date-time }

    LogMsg:
      name: LogMsg
      title: Admin broadcast — system, maintenance, compliance notices
      payload:
        type: object
        properties:
          type: { type: string, const: LogMsg }
          level: { type: string, enum: [info, warn, error] }
          message: { type: string }
          category: { type: string }
          ts: { type: string, format: date-time }

    LoggedOff:
      name: LoggedOff
      title: Server-initiated disconnect; do NOT auto-reconnect on intentional reasons
      payload:
        type: object
        properties:
          type: { type: string, const: LoggedOff }
          reason:
            type: string
            enum: [concurrent_session, admin_force, session_expired, killed, unknown]
          message: { type: string }
          ts: { type: string, format: date-time }

    IpUpdate:
      name: IP-Update
      payload:
        type: object
        properties:
          type: { type: string, const: IP-Update }
          sessionId: { type: string }
          previousIp: { type: string, nullable: true }
          currentIp: { type: string }

    # ===== Quote Service =====

    QuoteTick:
      name: Quote-Tick
      title: Per-symbol price update (mark / mid / book-top)
      payload:
        type: object
        properties:
          venue: { type: string, description: "Routing key. Never the market-data provider name — futures surface the exchange (CME/CBOT/NYMEX/COMEX, or the 'Futures' bucket). For paper futures this is 'paper'." }
          symbol: { type: string }
          mid: { type: string, nullable: true }
          bestBid: { type: string, nullable: true }
          bestAsk: { type: string, nullable: true }
          markPrice: { type: string, nullable: true }
          ts: { type: integer, description: 'Unix ms' }
          marketStatus:
            type: string
            enum: [open, closed]
            description: 'FUTURES (.FUT) ONLY — live market session state so a chart can flip a closed badge mid-view. Omitted for crypto venues / unknown status (treat absence as open).'

    # ===== Outbound webhook payloads =====

    AccountCreatedWebhook:
      name: account.created
      summary: New trading account provisioned
      payload:
        $ref: '#/components/schemas/WebhookEnvelope'

    AccountBalanceChangedWebhook:
      name: account.balance_changed
      summary: Account balance delta (deposit / withdraw / fill / commission)
      description: >-
        [LIVE] Payload carries accountId, accountNumber, venue, equityUsd,
        balanceUsd, marginUsedUsd, marginAvailableUsd, unrealizedPnlUsd,
        source, at. One delivery per (account, venue), debounced to coalesce
        a fill cascade into a single POST. `venue` 'paper' means the balance
        moved on the internally-matched book rather than at an exchange —
        that is the sandbox AND internalised (B-booked) real-money accounts.
        See 'Execution model'.
      payload:
        $ref: '#/components/schemas/WebhookEnvelope'

    AccountStatusChangedWebhook:
      name: account.status_changed
      summary: enabled / disabled / challenge_success / challenge_failed
      description: >-
        [LIVE] Fired on every prop-account state transition (created /
        activated / breached / passed / payout / expired / cancelled). Payload
        carries accountId, propAccountId, walletAddress, templateName,
        prevStatus, status, reason.
      payload:
        $ref: '#/components/schemas/WebhookEnvelope'

    PositionOvernightWebhook:
      name: position.overnight
      summary: Position held past session close
      description: '[RESERVED] Declared for forward-compat — no producer wired yet. Subscribers never receive this delivery today.'
      payload:
        $ref: '#/components/schemas/WebhookEnvelope'

    SubscriptionCreatedWebhook:
      name: subscription.created
      summary: New tenant / subscription provisioned
      description: '[RESERVED] Originally "new account lifecycle", superseded by account.created — no producer wired. Not the copy_subscriptions surface.'
      payload:
        $ref: '#/components/schemas/WebhookEnvelope'

    SubscriptionUpdatedWebhook:
      name: subscription.updated
      summary: Subscription state changed
      description: '[RESERVED] No producer wired yet. Subscribers never receive this delivery today.'
      payload:
        $ref: '#/components/schemas/WebhookEnvelope'

    TradeClosedWebhook:
      name: trade.closed
      summary: Round-trip trade closed with PnL
      description: >-
        [LIVE] Fired when an order intent reaches a terminal state — filter on
        the payload's `toState` ('filled' / 'cancelled' / 'rejected' /
        'expired') if you only want fills. Payload carries intentId, fromState,
        toState, venueOrderId, apiClientId, lastEvent, updatedAt.
        `venueOrderId` is the exchange's own id on a routed order and is null
        if the intent never dispatched. On an internalised (B-booked) intent it
        is an id we minted for the internal match: the firm was the
        counterparty, no order existed at any exchange, and the id resolves
        nowhere off-platform. See 'Execution model'.
      payload:
        $ref: '#/components/schemas/WebhookEnvelope'

  schemas:
    WebhookEnvelope:
      type: object
      required: [eventId, category, tenantId, ts, payload]
      properties:
        eventId:
          type: string
          format: uuid
          description: Stable per-event identifier. Multiple delivery attempts share this — use it as your dedup key.
        category:
          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
        tenantId:
          type: string
        ts:
          type: string
          format: date-time
        payload:
          type: object
          description: Category-specific payload. See category-specific message schemas above.
