openapi: 3.1.0

info:
  title: Ironflow API
  version: 1.0.0
  description: |
    Hyperliquid market data from Ironflow's own nodes: tick-level fills and
    trades, candles, funding rates, liquidations, mark prices, open interest,
    markets and wallet analytics over REST, live Server-Sent Events streams and
    read-only SQL. One schema across native HL perps, HIP-3 builder markets,
    HIP-4 outcome contracts and HL spot.

    The same data is a hosted MCP server for AI agents at
    `https://mcp.ironflow.sh/mcp`, and agents with a USDC wallet on Base can pay
    per call with x402, no key: the data GET endpoints are also served at
    `/x402/v1/...` (discovery: `https://api.ironflow.sh/openapi.json` and
    `https://api.ironflow.sh/.well-known/x402`).

    **Base URL:** `https://api.ironflow.sh`

    **Authentication:** Read endpoints work without a key (keyless access) or
    with a Bearer token in the `Authorization` header. Writes always need a key.
    ```
    Authorization: Bearer if_your_api_key
    ```
    Get a free key at https://ironflow.sh/key.

    **Access levels:**

    | Access | Requests per minute | History | Max range per request |
    |---|---|---|---|
    | No key | 30 per IP (300 a day) | 24 hours | 1 hour |
    | Free key | 60 | 14 days | 6 hours |
    | Explorer | 100 | 90 days | 24 hours |
    | Builder | 1,000 | 1 year | 7 days |
    | Enterprise | 10,000 | full | unlimited |

    Without a market filter, high-volume endpoints allow at most 4 hours per request.
    `/v1/trades` filtered to one perp or spot market allows 24 hours per request
    without a key and on a free key.
    Each caller can also read a set number of rows per hour: 2 billion without a key,
    10 billion on a free key, 100 billion on Explorer, 500 billion on Builder, none set
    on Enterprise. REST and SQL count separately. Over it, REST answers 429
    `READ_BUDGET_EXCEEDED` until the next hour.
    A header that is present but malformed returns 401; keyless access applies only
    when no `Authorization` header is sent. The keyless 300 a day is a rolling 24
    hours per IP: past it, `KEYLESS_DAILY_LIMIT` (429, or 402 with x402 payment
    terms) carries a `Retry-After` of the seconds until the next call is allowed.

    **Data availability:** fills and trades start on 25 May 2025; other
    datasets start later (funding 2025-09-27, open interest and mark prices
    2026-04-29). Bulk history is free to download from
    https://huggingface.co/ironflow.

    **Timestamps:** All timestamps are Unix milliseconds (int64).

    **Pagination:** Paginated endpoints return a `cursor` in `pagination.next_cursor`.
    Pass it as the `cursor` query parameter to fetch the next page.
    When `pagination.has_more` is `false`, you have reached the last page.

    **Notices:** a successful JSON object may start with `notice`, the
    `X-Ironflow-Notice` header in the body: what was served when it differs from
    what was asked (a keyless sample, a shortened history, a guessed path) and
    how to get the rest.

    **Guessed paths:** a few paths callers guess are answered by the endpoint
    they mean, with a `notice` and the `X-Ironflow-Canonical` header naming it:
    `/v1/liquidations/aggregate`, `/v1/summary/liquidations` and similar by
    `/v1/liquidations/summary`, `/v1/liquidations/buckets` and `heatmap` by
    `/v1/analytics/liquidation-levels`, and `/v1/liquidations/map/BTC-PERP` by
    `/v1/liquidations/map?market=BTC-PERP`. A path with trailing `.`, `,`, `;` or
    `)` (`/v1/query.`, copied from a sentence) is answered by the endpoint without
    them, with the same method, query and body. OpenAI-style paths (`/v1/models`,
    `/v1/chat/completions`) answer 404: this is not an LLM API, and agents use the
    MCP server above.

  contact:
    name: Ironflow Support
    url: https://ironflow.sh

servers:
  - url: https://api.ironflow.sh
    description: Production

# An empty requirement ({}) makes the key optional: reads work keyless.
security:
  - bearerAuth: []
  - {}

tags:
  - name: Hyperliquid RPC
    description: Your API key as a Hyperliquid /info URL, on your plan's limits.
  - name: Market Data
    description: Historical tick-level market data stored in ClickHouse.
  - name: Analytics
    description: Aggregated analytics. Net flows, funding stats and the liquidation map markets work without a key; liquidation levels, the liquidation map and the vault leaderboard return a sample without one; order flow needs a key (a free key works).
  - name: Cohorts
    description: Predefined and custom address groups for filtering market data.
  - name: Alerts
    description: Liquidation alerts that Ironflow posts to your webhook, Discord channel, Slack channel or Telegram chat.
  - name: Ironbox
    description: Secure, sandboxed environments in Tokyo, next to Hyperliquid, where a trading agent runs with Ironflow's data, analytics, alerts and execution. Any API key, free included.
  - name: Query
    description: Read-only ClickHouse SQL over curated Hyperliquid tables (hl.*), limited by tier.
  - name: Billing
    description: Paid plans, paid in USDC on Hyperliquid or with x402 on Base. No card, no account page.
  - name: System
    description: Health checks and metadata endpoints.
  - name: TradingApp
    description: The trading terminal's own endpoints (terminal.ironflow.sh), wallet-session auth. Not part of the public data API; left out of the docs copy.

# ─── Paths ──────────────────────────────────────────────────────────────────

paths:

  /v1/trades:
    get:
      operationId: listTrades
      summary: List trades
      description: |
        Query historical tick-level trades. Returns all trades across all participants
        for the given market or markets (`market=BTC-PERP,ETH-PERP`, up to 50, in one
        list: `limit`, `order` and the cursor cover them together). Full tick
        resolution — every trade is stored.

        **History windows by tier:**
        - No key: 24 hours
        - Free key: 14 days
        - Explorer: 90 days
        - Builder: 1 year
        - Enterprise: everything stored (fills from 25 May 2025)

        **Per-request query window (max time range per request):**
        - With one perp or spot market: no key 24h, free key 24h, Explorer 24h, Builder 7d, Enterprise unlimited
        - With one outcome market or a list of markets: no key 1h, free key 6h, then as above
        - Without market filter: 4h (all tiers; no key 1h)
        - Returns `QUERY_WINDOW_EXCEEDED` (400) if exceeded. Paginate by splitting into smaller from/to windows.
      tags: [Market Data]
      parameters:
        - name: order
          in: query
          required: false
          description: Sort direction by timestamp. `asc` (default) returns the oldest rows of the window first; `desc` returns the newest first — use with `limit` to get the freshest trades.
          schema:
            type: string
            enum: [asc, desc]
            default: asc
        - $ref: '#/components/parameters/source'
        - name: market
          in: query
          description: |
            One market or a comma-separated list (`xyz:NVDA-PERP,xyz:TSLA-PERP`),
            up to 50, as on `/v1/liquidations`. Hyperliquid coin names (`BTC`,
            `@107`, `xyz:NVDA`) are read as their markets; an entry that names no
            market is ignored and named in the `notice`. Omit for every market.
          schema:
            type: string
            example: BTC-PERP,ETH-PERP
        - $ref: '#/components/parameters/base_market'
        - $ref: '#/components/parameters/issuer'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - $ref: '#/components/parameters/address'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - name: min_size
          in: query
          description: Minimum trade size filter (decimal string, e.g. `"1.0"`).
          schema:
            type: string
            example: "1.0"
      responses:
        '200':
          description: Paginated list of trades.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TradeList'
              example:
                data:
                  - source: hyperliquid
                    market: BTC-PERP
                    issuer: ""
                    base_market: BTC-PERP
                    market_type: perp
                    timestamp: 1718400000000
                    block_number: 12345678
                    sequence: 42
                    price: "67432.10"
                    size: "1.5"
                    side: buy
                    address: "0xabc123"
                    fee: "0.38"
                    realized_pnl: "0"
                    hash: "0x9f1a2b"
                  - source: hyperliquid
                    market: "flx:GAS-PERP"
                    issuer: "flx"
                    base_market: GAS-PERP
                    market_type: perp
                    timestamp: 1718400001000
                    block_number: 12345679
                    sequence: 43
                    price: "1.5021"
                    size: "12000"
                    side: buy
                    address: "0xdef456"
                    fee: "0.05"
                    realized_pnl: "0"
                    hash: "0x9f1a2c"
                pagination:
                  next_cursor: "MTcxODQwMDAwMDAwMC00Mg=="
                  has_more: true
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/fills:
    get:
      operationId: listFills
      summary: List fills
      description: |
        Query fills for a specific address. Unlike `/v1/trades` (all participants),
        fills are scoped to a single account.

        Includes role (`maker`/`taker`), realized PnL, and fee paid per fill.

        Without `address` you get the latest fills from every wallet, in `market`
        when you give one (every market otherwise), newest first, with a `notice`:
        the rows `GET /v1/trades?order=desc` returns, with its limits. With a market
        and no `from`/`to` it looks back a day, so a quiet market still shows its last
        fills. `order=asc` reverses them.
      tags: [Market Data]
      parameters:
        - $ref: '#/components/parameters/source'
        - name: address
          in: query
          required: false
          description: Wallet address to query fills for. Omit it for every wallet's latest fills.
          schema:
            type: string
            example: "0xabc123def456"
        - $ref: '#/components/parameters/market'
        - $ref: '#/components/parameters/base_market'
        - $ref: '#/components/parameters/issuer'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Paginated list of fills.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TradeList'
              example:
                data:
                  - source: hyperliquid
                    market: BTC-PERP
                    issuer: ""
                    base_market: BTC-PERP
                    market_type: perp
                    timestamp: 1718400000000
                    block_number: 12345678
                    sequence: 15
                    price: "67432.10"
                    size: "0.5"
                    side: buy
                    address: "0xabc123def456"
                    fee: "0.17"
                    realized_pnl: "-12.50"
                    hash: "0x9f1a2b"
                  - source: hyperliquid
                    market: "xyz:NVDA-PERP"
                    issuer: "xyz"
                    base_market: NVDA-PERP
                    market_type: perp
                    timestamp: 1718400002000
                    block_number: 12345680
                    sequence: 16
                    price: "881.20"
                    size: "10"
                    side: sell
                    address: "0xabc123def456"
                    fee: "0.04"
                    realized_pnl: "27.50"
                    hash: "0x9f1a2d"
                pagination:
                  next_cursor: null
                  has_more: false
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/candles:
    get:
      operationId: listCandles
      summary: List OHLCV candles
      description: |
        OHLCV candles built from every fill our node records: hourly and
        longer intervals read the hourly candle table, shorter ones and the
        current hour read fills directly.

        Perps and spot count each trade once. They include off-book fills
        that Hyperliquid's own candles leave out (backstop liquidations,
        auto-deleveraging, vault netting, delisting settlements), so in
        liquidation-heavy hours the trade count, high and low can run
        beyond Hyperliquid's.

        HIP-4 outcome candles match Hyperliquid's: the taker trades of both
        coins of an outcome, which share one order book, with the other
        coin's price as 1 - price.

        Supported intervals: `1m`, `5m`, `15m`, `1h`, `4h`, `1d`

        Without `from`, the window covers `limit` candles back from now (at
        most 30 days), bounded by your access level's history depth. So 24
        hourly candles return the last 24 hours, with or without a key.
      tags: [Market Data]
      parameters:
        - $ref: '#/components/parameters/source'
        - name: market
          in: query
          required: true
          description: Market symbol (e.g. `BTC-PERP`).
          schema:
            type: string
            example: BTC-PERP
        - name: interval
          in: query
          description: Candle interval. Defaults to `1h`, said in the `X-Ironflow-Notice` header.
          schema:
            type: string
            enum: [1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 8h, 12h, 1d, 3d, 1w]
            example: 1h
        - $ref: '#/components/parameters/base_market'
        - $ref: '#/components/parameters/issuer'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Paginated list of candles.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CandleList'
              example:
                data:
                  - source: hyperliquid
                    market: BTC-PERP
                    issuer: ""
                    base_market: BTC-PERP
                    interval: 1h
                    timestamp: 1718398800000
                    open: "67400.00"
                    high: "67500.50"
                    low: "67380.00"
                    close: "67432.10"
                    volume: "1523.45"
                    trade_count: 8412
                pagination:
                  next_cursor: "MTcxODM5ODgwMDAwMA=="
                  has_more: true
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/liquidations:
    get:
      operationId: listLiquidations
      summary: List liquidations
      description: |
        Query historical liquidation events. Detected natively from Hyperliquid
        fill events — not heuristic. Every forced position closure is captured.

        Polling for new liquidations: pass `after=<poll_cursor>` from the
        previous response to get only newer rows, and treat rows up to
        `complete_until` as final (no need to lag your window).
        `/v1/liquidations/stream` pushes them as they happen instead.
      tags: [Market Data]
      parameters:
        - name: order
          in: query
          required: false
          description: Sort direction by timestamp. `asc` (default) returns the oldest rows of the window first; `desc` returns the newest first, so `order=desc&limit=1` is the latest value.
          schema:
            type: string
            enum: [asc, desc]
            default: asc
        - $ref: '#/components/parameters/source'
        - name: market
          in: query
          description: |
            One market or a comma-separated list (`BTC-PERP,ETH-PERP`), up to
            50. A bare coin (`BTC`) matches its perp; an entry that names no
            market is ignored and named in the `notice`. Omit for every market
            in one call.
          schema:
            type: string
            example: BTC-PERP,ETH-PERP
        - $ref: '#/components/parameters/base_market'
        - $ref: '#/components/parameters/issuer'
        - $ref: '#/components/parameters/address'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - name: after
          in: query
          description: |
            `poll_cursor` of a previous response: only rows newer than the
            last one it returned. A Unix time (seconds or milliseconds) also
            works: rows after that time. The window starts there unless
            `from` is set.
          schema:
            type: string
      responses:
        '200':
          description: Paginated list of liquidation events.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LiquidationList'
              example:
                data:
                  - source: hyperliquid
                    market: BTC-PERP
                    issuer: ""
                    base_market: BTC-PERP
                    market_type: perp
                    timestamp: 1718400000000
                    block_number: 12345678
                    address: "0xabc123"
                    side: buy
                    price: "67380.00"
                    size: "2.5"
                    account_value: "0.00"
                    leverage_type: isolated
                    bankrupt: false
                    sequence: 1
                pagination:
                  next_cursor: null
                  has_more: false
        '400': { $ref: '#/components/responses/PerpOnlyBadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/liquidations/summary:
    get:
      operationId: liquidationSummary
      summary: Liquidation summary by market and window
      description: |
        Long and short liquidated USD, liquidation count, wallets and the
        largest liquidation, per market and in total, over trailing windows.
        Liquidation fills are grouped into events (one wallet, one market,
        one minute). Cached for 15 seconds.

        The windows end now: `from` and `to` are not read, and a call that sends
        them gets a `notice` saying so (for a past range, use `GET /v1/liquidations`).
        Guessed paths such as `/v1/liquidations/aggregate` and
        `/v1/summary/liquidations` are answered by this endpoint.
      tags: [Market Data]
      parameters:
        - name: market
          in: query
          description: One market or a comma list, up to 50. Omit for every market.
          schema: { type: string, example: BTC-PERP,ETH-PERP }
        - name: windows
          in: query
          description: |
            Comma list of 15m, 1h, 4h, 12h, 24h, 7d. Keyless calls reach 24h: a longer window
            is left out with a `notice`, and when every window is longer, the answer covers 24h.
          schema: { type: string, default: 1h,4h,24h }
        - name: bucket
          in: query
          description: 15m, 1h or 4h. Adds a long/short series per market over the longest window; needs market with 1 to 10 markets.
          schema: { type: string }
      responses:
        '200':
          description: Totals per window.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LiquidationSummary'
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/liquidations/stream:
    get:
      operationId: streamLiquidations
      summary: Stream liquidations live
      description: |
        Server-Sent Events feed of Hyperliquid liquidations as they land,
        usually a few seconds behind the chain (longer while our node
        catches up after a heavy cascade). Each event is one row in the same
        shape as `GET /v1/liquidations`, so you do not need to poll that
        endpoint for new rows.

        Works without a key: one stream per IP, and opening it does not
        count toward the keyless daily budget. Keys can hold more streams
        at once (free 2, explorer 5, builder 20, enterprise 100); use one
        stream with several markets rather than one per market.

        Wire format: every event has `id: <timestamp>-<sequence>`,
        `event: liquidation` and a JSON `data` line. Comment lines
        (`: ping`) keep the connection open every 5 s. After an hour, and
        when the server restarts for a deploy, it sends `event: reconnect`
        and closes; reconnect with the
        `Last-Event-ID` header (EventSource does this for you) or
        `?last_event_id=` and the rows you missed in the last 5 minutes
        are sent first. A client that falls far behind gets
        `event: error` with code `SLOW_CONSUMER` and should reconnect the
        same way. For older history, page `GET /v1/liquidations`.

        ```bash
        curl -N -H "Authorization: Bearer $IRONFLOW_API_KEY" \
          "https://api.ironflow.sh/v1/liquidations/stream?market=BTC,ETH&min_usd=10000"
        ```
      tags: [Market Data]
      parameters:
        - name: market
          in: query
          description: |
            Comma-separated markets to include (`BTC-PERP,flx:GAS-PERP`). A
            bare coin (`BTC`) matches its perp. Up to 50. Omit for every
            market.
          schema:
            type: string
            example: BTC-PERP,ETH-PERP
        - name: address
          in: query
          description: Only liquidations of this wallet.
          schema:
            type: string
            example: "0x0000000000000000000000000000000000000001"
        - name: min_usd
          in: query
          description: Only liquidations of at least this notional (price × size) in USD.
          schema:
            type: number
            minimum: 0
            example: 10000
        - name: last_event_id
          in: query
          description: |
            Resume after this event id (`<timestamp>-<sequence>`), for clients
            that cannot set the `Last-Event-ID` header. Ids older than 5
            minutes are ignored.
          schema:
            type: string
            example: 1718400000000-1
      responses:
        '200':
          description: |
            An open event stream. Each `data` line is a `Liquidation`.
          content:
            text/event-stream:
              schema:
                type: string
              example: |
                retry: 3000
                : connected

                id: 1718400000000-1
                event: liquidation
                data: {"source":"hyperliquid","market":"BTC-PERP","issuer":"","base_market":"BTC-PERP","market_type":"perp","timestamp":1718400000000,"block_number":12345678,"address":"0xabc123","side":"sell","price":"67380.00","size":"2.5","account_value":"0.00","leverage_type":"cross","bankrupt":false,"sequence":1}
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: |
            A malformed or deactivated key. Send no Authorization header for
            keyless access, or a valid key from https://ironflow.sh/key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: |
            Rate limited, or this key already has its tier's number of open
            streams (code `STREAM_LIMIT`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/trades/stream:
    get:
      operationId: streamTrades
      summary: Stream trades live
      description: |
        Server-Sent Events feed of Hyperliquid fills as they land, usually a
        few seconds behind the chain. Each event is one row in the same shape
        as `GET /v1/trades` (one per side of a trade), so you do not need to
        poll that endpoint for new rows.

        Works without a key: one stream per IP, and opening it does not
        count toward the keyless daily budget. Keys can hold more streams
        at once (free 2, explorer 5, builder 20, enterprise 100), counted
        separately from liquidation streams; use one stream with several
        markets rather than one per market. Every market at once is about
        200 fills a second; `min_usd` keeps only the large ones.

        Wire format: every event has `id: <timestamp>-<sequence>`,
        `event: trade` and a JSON `data` line. Comment lines (`: ping`)
        keep the connection open every 5 s. After an hour, and when the
        server restarts for a deploy, it sends `event: reconnect` and closes; reconnect with the `Last-Event-ID`
        header (EventSource does this for you) or `?last_event_id=` and the
        fills you missed in the last 5 minutes are sent first. A client that
        falls far behind gets `event: error` with code `SLOW_CONSUMER` and
        should reconnect the same way. For older history, page
        `GET /v1/trades`.

        ```bash
        curl -N "https://api.ironflow.sh/v1/trades/stream?market=BTC,ETH&min_usd=50000"
        ```
      tags: [Market Data]
      parameters:
        - name: market
          in: query
          description: |
            Comma-separated markets to include (`BTC-PERP,xyz:NVDA-PERP`). A
            bare coin (`BTC`) matches its perp. Up to 50. Omit for every
            market.
          schema:
            type: string
            example: BTC-PERP,ETH-PERP
        - name: address
          in: query
          description: Only fills of this wallet.
          schema:
            type: string
            example: "0x0000000000000000000000000000000000000001"
        - name: min_usd
          in: query
          description: Only fills of at least this notional (price × size) in USD.
          schema:
            type: number
            minimum: 0
            example: 50000
        - name: last_event_id
          in: query
          description: |
            Resume after this event id (`<timestamp>-<sequence>`), for clients
            that cannot set the `Last-Event-ID` header. Ids older than 5
            minutes are ignored.
          schema:
            type: string
            example: 1718400000000-1
      responses:
        '200':
          description: |
            An open event stream. Each `data` line is a `Trade`.
          content:
            text/event-stream:
              schema:
                type: string
              example: |
                retry: 3000
                : connected

                id: 1718400000000-7
                event: trade
                data: {"source":"hyperliquid","market":"BTC-PERP","issuer":"","base_market":"BTC-PERP","market_type":"perp","timestamp":1718400000000,"block_number":12345678,"sequence":7,"price":"67380.0","size":"0.75","side":"buy","address":"0xabc123","fee":"12.13","realized_pnl":"0","hash":"0x1f2e..."}
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: |
            A malformed or deactivated key. Send no Authorization header for
            keyless access, or a valid key from https://ironflow.sh/key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: |
            Rate limited, or this key already has its tier's number of open
            trade streams (code `STREAM_LIMIT`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /v1/funding:
    get:
      operationId: listFundingRates
      summary: List funding rates
      description: |
        Query historical funding rate snapshots. Funding rates on Hyperliquid
        are paid every hour. Negative rates mean shorts pay longs.
      tags: [Market Data]
      parameters:
        - name: order
          in: query
          required: false
          description: Sort direction by timestamp. `asc` (default) returns the oldest rows of the window first; `desc` returns the newest first, so `order=desc&limit=1` is the latest value.
          schema:
            type: string
            enum: [asc, desc]
            default: asc
        - $ref: '#/components/parameters/source'
        - name: market
          in: query
          required: true
          description: Market symbol (e.g. `BTC-PERP`).
          schema:
            type: string
            example: BTC-PERP
        - $ref: '#/components/parameters/base_market'
        - $ref: '#/components/parameters/issuer'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Paginated list of funding rate snapshots.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FundingRateList'
              example:
                data:
                  - source: hyperliquid
                    market: BTC-PERP
                    issuer: ""
                    base_market: BTC-PERP
                    timestamp: 1718398800000
                    rate: "0.0001"
                    sequence: 1
                pagination:
                  next_cursor: null
                  has_more: false
        '400': { $ref: '#/components/responses/PerpOnlyBadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/open-interest/changes:
    get:
      operationId: openInterestChanges
      summary: Open interest movers
      description: |
        Perp markets ranked by how their open interest moved over the window: first and last
        sample per market, valued at the latest mark. Sorted by absolute USD change (default) or
        percent. Markets under min_oi_usd at both ends are left out. Cached for 60 seconds.
      tags: [Market Data]
      security:
        - {}
        - bearerAuth: []
      parameters:
        - { name: window, in: query, schema: { type: string, enum: ["1h", "4h", "24h", "7d"], default: "24h" } }
        - { name: sort, in: query, schema: { type: string, enum: [usd, pct], default: usd } }
        - { name: dex, in: query, description: "HIP-3 dex (xyz, ...) or native for validator perps", schema: { type: string } }
        - { name: min_oi_usd, in: query, schema: { type: number, default: 100000 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 25 } }
      responses:
        '200':
          description: Ranked markets.
          content:
            application/json:
              schema:
                type: object
                properties:
                  window: { type: string }
                  sort: { type: string }
                  count: { type: integer }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        market: { type: string, example: "ETH-PERP" }
                        dex: { type: string }
                        oi_start: { type: number, description: Open interest in coins at the window start }
                        oi_end: { type: number, description: Open interest in coins now }
                        change_pct: { type: number }
                        change_usd: { type: number }
                        oi_end_usd: { type: number }
                        mark: { type: number }
  /v1/premium:
    get:
      operationId: premium
      summary: Mark price against oracle price
      description: |
        A perp's mark price against its oracle price per bucket, with the premium in basis points
        ((mark / oracle - 1) x 10,000) and a summary. On HIP-3 stock, index and commodity perps the
        oracle follows the underlying, so this is the perp against what it tracks. Default 1h buckets
        over 7 days, at most 2,000 buckets.
      tags: [Market Data]
      security:
        - {}
        - bearerAuth: []
      parameters:
        - { name: market, in: query, required: true, schema: { type: string }, example: "xyz:NVDA-PERP" }
        - { name: interval, in: query, schema: { type: string, enum: ["1m", "5m", "15m", "1h", "4h", "1d"], default: "1h" } }
        - { name: from, in: query, schema: { type: integer }, description: Unix ms }
        - { name: to, in: query, schema: { type: integer }, description: Unix ms }
      responses:
        '200':
          description: Premium series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  market: { type: string }
                  interval: { type: string }
                  summary:
                    type: object
                    properties:
                      latest_bps: { type: number }
                      mean_bps: { type: number }
                      min_bps: { type: number }
                      max_bps: { type: number }
                      buckets: { type: integer }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        t: { type: integer, description: Bucket start, unix ms }
                        mark: { type: number }
                        oracle: { type: number }
                        premium_bps: { type: number }
  /v1/positions:
    get:
      operationId: positionsAt
      summary: A wallet's positions at a past time
      description: |
        Perp positions a wallet held at `at`: today's positions from Hyperliquid (every dex) minus
        the wallet's fills after `at`, so sizes are exact. mark_price and notional use the last mark
        at or before `at`. Up to 90 days back, within your key's history window. Needs a key.
      tags: [Analytics]
      security:
        - bearerAuth: []
      parameters:
        - { name: address, in: query, required: true, schema: { type: string } }
        - { name: at, in: query, required: true, description: "Unix ms, unix seconds or ISO 8601", schema: { type: string }, example: "2026-11-01T12:00:00Z" }
      responses:
        '200':
          description: Positions at `at`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  address: { type: string }
                  at: { type: integer }
                  count: { type: integer }
                  method: { type: string }
                  positions:
                    type: array
                    items:
                      type: object
                      properties:
                        market: { type: string, example: "BTC-PERP" }
                        side: { type: string, enum: [long, short] }
                        size: { type: number, description: Signed size in coins }
                        mark_price: { type: number }
                        notional: { type: number }
                        size_now: { type: number }
                        fills_since: { type: integer }
  /v1/adl:
    get:
      operationId: autoDeleveraging
      summary: Auto-deleveraging fills
      description: |
        Fills where Hyperliquid auto-deleveraged a profitable position against a liquidation its
        backstop could not absorb, newest first, across markets or for one. tid is shared with the
        liquidated side of the same trade. Recorded from 2026-08-01 00:00 UTC. Default window 7 days.
      tags: [Market Data]
      security:
        - {}
        - bearerAuth: []
      parameters:
        - { name: market, in: query, schema: { type: string } }
        - { name: from, in: query, schema: { type: integer }, description: Unix ms }
        - { name: to, in: query, schema: { type: integer }, description: Unix ms }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
      responses:
        '200':
          description: Auto-deleveraging fills.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count: { type: integer }
                  recorded_from: { type: integer }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        timestamp: { type: integer }
                        market: { type: string }
                        address: { type: string }
                        side: { type: string }
                        price: { type: number }
                        size: { type: number }
                        notional: { type: number }
                        realized_pnl: { type: number }
                        tid: { type: integer }
                        block: { type: integer }
                        hash: { type: string }
  /v1/open-interest:
    get:
      operationId: listOpenInterest
      summary: List open interest
      description: |
        Query open interest snapshots (10-second resolution).
        Includes 24-hour trading volume alongside open interest.

        Without `market` (and without `from`, `to` and `cursor`, which need one)
        you get the latest open interest of every perp market in the same row
        shape, largest in USD first, with a `notice`. `limit` caps the markets
        (default 100, up to 1000); `issuer` and `base_market` filter them;
        `pagination.has_more` says whether more markets exist (there is no cursor).
        `GET /v1/markets/snapshot` adds USD values, prices and funding.
      tags: [Market Data]
      parameters:
        - name: order
          in: query
          required: false
          description: Sort direction by timestamp. `asc` (default) returns the oldest rows of the window first; `desc` returns the newest first, so `order=desc&limit=1` is the latest value.
          schema:
            type: string
            enum: [asc, desc]
            default: asc
        - $ref: '#/components/parameters/source'
        - name: market
          in: query
          required: false
          description: Market symbol (e.g. `BTC-PERP`). Omit it for the latest value of every perp market.
          schema:
            type: string
            example: BTC-PERP
        - $ref: '#/components/parameters/base_market'
        - $ref: '#/components/parameters/issuer'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Paginated list of open interest snapshots.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OpenInterestList'
              example:
                data:
                  - source: hyperliquid
                    market: BTC-PERP
                    issuer: ""
                    base_market: BTC-PERP
                    timestamp: 1718400000000
                    open_interest: "45231.50"
                    volume_24h: "1892340.00"
                pagination:
                  next_cursor: null
                  has_more: false
        '400': { $ref: '#/components/responses/PerpOnlyBadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/mark-prices:
    get:
      operationId: listMarkPrices
      summary: List mark and oracle prices
      description: |
        Query mark price and oracle price snapshots (10-second resolution
        for native perps, 30 seconds for HIP-3), one row per poll.

        - **Mark price:** Used for PnL calculation and liquidation triggers.
        - **Oracle price:** Index price from external sources, from the same poll (from 2026-09-04).
        - **Funding rate:** always `"0"` here; use `/v1/funding`.
      tags: [Market Data]
      parameters:
        - name: order
          in: query
          required: false
          description: Sort direction by timestamp. `asc` (default) returns the oldest rows of the window first; `desc` returns the newest first, so `order=desc&limit=1` is the latest value.
          schema:
            type: string
            enum: [asc, desc]
            default: asc
        - $ref: '#/components/parameters/source'
        - name: market
          in: query
          required: true
          description: Market symbol (e.g. `BTC-PERP`).
          schema:
            type: string
            example: BTC-PERP
        - $ref: '#/components/parameters/base_market'
        - $ref: '#/components/parameters/issuer'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Paginated list of mark/oracle price snapshots.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MarkPriceList'
              example:
                data:
                  - source: hyperliquid
                    market: BTC-PERP
                    issuer: ""
                    base_market: BTC-PERP
                    timestamp: 1718400000000
                    mark_price: "67432.10"
                    oracle_price: "67428.50"
                    funding_rate: "0.0001"
                pagination:
                  next_cursor: null
                  has_more: false
        '400': { $ref: '#/components/responses/PerpOnlyBadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/vault-operations:
    get:
      operationId: listVaultOperations
      summary: List vault operations
      description: |
        Query vault deposit and withdrawal events. Requires at least one of `vault` or `address`.
      tags: [Market Data]
      parameters:
        - $ref: '#/components/parameters/source'
        - name: vault
          in: query
          description: Vault address to filter by.
          schema: { type: string }
        - $ref: '#/components/parameters/address'
        - name: operation
          in: query
          description: Filter by operation type.
          schema: { type: string, enum: [deposit, withdraw] }
        - $ref: '#/components/parameters/base_market'
        - $ref: '#/components/parameters/issuer'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Paginated list of vault operations.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VaultOperationList'
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  # ─── Analytics ─────────────────────────────────────────────────────────────

  /v1/analytics/net-flows:
    get:
      operationId: getNetFlows
      summary: Net deposit/withdrawal flows
      description: |
        Aggregated deposits minus withdrawals over time intervals.
        Shows capital inflows and outflows. Works without a key.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/source'
        - $ref: '#/components/parameters/address'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: interval
          in: query
          description: Aggregation interval.
          schema: { type: string, enum: [1h, 4h, 8h, 1d, 7d], default: 1d }
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Net flow time series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/NetFlow' }
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/analytics/liquidation-levels:
    get:
      operationId: getLiquidationLevels
      summary: Liquidations by price level
      description: |
        Liquidations that happened, bucketed by price and side: where longs and shorts were
        liquidated, for heatmaps. Use `bucket_size=auto` to size buckets to the market (about
        0.25% of price), which works for any coin from kPEPE to BTC.
        Without a key you get a sample: the 10 busiest levels in the last 24 hours, with a
        `notice` that names any parameter the sample did not honour (`limit` above 10,
        `window` past 24 hours). Any key (a free key from https://ironflow.sh/key works)
        returns every level and your tier's history. `/v1/liquidations/buckets` and
        `/v1/liquidations/heatmap` are answered by this endpoint.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/source'
        - name: market
          in: query
          required: true
          schema: { type: string, example: BTC-PERP }
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: window
          in: query
          description: A span back from now (`15m`, `24h`, `7d`, up to `365d`), used when `from` is absent. The default is 7 days, cut to your history.
          schema: { type: string, example: 24h }
        - name: bucket_size
          in: query
          description: Price bucket width. `auto` sizes it to the market; the fixed widths suit coins priced above a few dollars.
          schema: { type: string, enum: [auto, "1", "5", "10", "50", "100", "500", "1000", "5000"], default: "100" }
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Liquidation level buckets.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/LiquidationLevel' }
                  bucket_size:
                    type: string
                    description: The bucket width used (the one picked when you sent auto).
                    example: "200"
                  notice:
                    type: string
                    description: Present on keyless calls, which return a sample.
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/liquidations/map:
    get:
      operationId: getLiquidationMap
      summary: Liquidation map (where open positions would be liquidated)
      description: |
        Where open Hyperliquid perp positions would be liquidated, by price, now:
        longs below the mark, shorts above it. Built from every open position in our
        node's state snapshot, which the node writes about every 12 minutes. `snapshot`
        names the snapshot and `as_of` is when the positions were last priced.

        Each level gives the notional (size times liquidation price) and position count
        liquidated in that price range, and `cum_long_usd` / `cum_short_usd`: everything
        liquidated between the mark and that level, which is what a move to that price
        would take out. The service sums positions on a grid of 0.25% of mark; each grid
        level is spread over the `bucket_size` buckets it overlaps in proportion to the
        overlap, so notional totals stay exact and position counts stay whole.

        Without a key you get a sample: the 10 largest levels within 10% of mark, with a
        `notice`. Any key (a free key from https://ironflow.sh/key works) returns every
        level out to `range_pct`.

        The map is cached for 10 seconds. When the map service does not answer, the last
        map (up to 30 minutes old) is returned with `stale: true`; with nothing to return
        the answer is 503 with `Retry-After`. GET /v1/liquidations/map/markets lists the
        markets. `/v1/liquidations/map/BTC-PERP` is answered as `?market=BTC-PERP`.
      tags: [Analytics]
      parameters:
        - name: market
          in: query
          required: true
          description: Perp symbol (BTC-PERP, xyz:NVDA-PERP) or Hyperliquid coin name (BTC, xyz:NVDA).
          schema: { type: string, example: BTC-PERP }
        - name: bucket_size
          in: query
          description: |
            Price bucket width. `auto` is about 0.25% of mark rounded to 1, 2 or 5 times a power
            of ten (200 for BTC near 75,000, 0.2 for HYPE near 78). A number sets the width; one
            finer than the map's grid is raised to the auto width, with a notice.
          schema: { type: string, default: auto, example: "500" }
        - name: range_pct
          in: query
          description: How far from the mark to cover, in percent each way. Keyless calls cover 10.
          schema: { type: number, exclusiveMinimum: 0, maximum: 50, default: 20 }
        - name: side
          in: query
          description: Longs, shorts or both.
          schema: { type: string, enum: [both, long, short], default: both }
      responses:
        '200':
          description: The liquidation map.
          headers:
            X-Ironflow-Notice:
              description: Present on keyless calls (a sample) and when bucket_size was raised.
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LiquidationMap'
        '400': { $ref: '#/components/responses/BadRequest' }
        '404':
          description: No map for this market (unknown, not a perp, or no open positions).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: NOT_FOUND
                  message: 'no liquidation map for "HYPE/USDC": the map covers Hyperliquid perps with open positions, such as BTC-PERP or xyz:NVDA-PERP. GET /v1/liquidations/map/markets lists them'
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          $ref: '#/components/responses/LiquidationMapUnavailable'

  /v1/liquidations/map/markets:
    get:
      operationId: listLiquidationMapMarkets
      summary: Markets in the liquidation map
      description: |
        Every market the liquidation map covers, with its mark and the long and short
        notional that has a liquidation price, largest first. Cached for 10 seconds,
        returned with `stale: true` when the map service does not answer. No key needed.
      tags: [Analytics]
      responses:
        '200':
          description: Markets, largest first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LiquidationMapMarkets'
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          $ref: '#/components/responses/LiquidationMapUnavailable'

  /v1/analytics/vault-leaderboard:
    get:
      operationId: getVaultLeaderboard
      summary: Vault leaderboard
      description: |
        Vaults ranked by net deposits: shows which vaults are attracting capital.
        The default window is the last 7 days, cut to your history.
        Without a key you get a sample: the 10 vaults with the largest net deposits in
        the last 24 hours, with a `notice`. Any key (a free key from
        https://ironflow.sh/key works) returns up to `limit` vaults over your tier's history.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/source'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Ranked vault list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  notice:
                    type: string
                    description: Present on keyless calls, which return a sample.
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/VaultLeaderboardEntry' }
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/analytics/funding-stats:
    get:
      operationId: getFundingStats
      summary: Funding rate statistics
      description: |
        Funding rate statistics over time including annualized rates.
        Works without a key.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/source'
        - name: market
          in: query
          required: true
          schema: { type: string, example: BTC-PERP }
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: interval
          in: query
          schema: { type: string, enum: [1h, 4h, 8h, 1d, 7d], default: 1d }
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Funding statistics time series.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/FundingStats' }
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  # ─── Per-address wallet analytics (v0.31.0+) ───────────────────────────────

  /v1/analytics/user-state:
    get:
      operationId: getUserState
      summary: Current open positions + unrealized P&L
      description: |
        Snapshot of an address's current open perp positions, entry prices,
        mark prices, position values, and unrealized P&L. Mirrors the shape
        of Hyperliquid's `clearinghouseState` `/info` request so callers
        porting from HL's API can reuse their parsing.

        Sign convention: `size` is signed (positive = long, negative = short).
        `unrealized_pnl = size * (mark_price - entry_price)`. Markets with
        `|size| < 1e-9` are omitted (effectively flat).

        Returns `as_of_ts` (unix ms) carrying the wall-clock of the latest
        `mark_prices` row used; clients can detect staleness if the poller
        is lagging.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/address'
      responses:
        '200':
          description: Current state for the address.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserState' }
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/analytics/user-funding:
    get:
      operationId: getUserFunding
      summary: Funding payments per market per bucket
      description: |
        Daily or hourly funding payments per market for an address: the
        exact payments from Hyperliquid's ledger (the same rows as its
        `userFunding` API), native and HIP-3 perps, read from our node's
        hourly funding settlements. For SQL across many wallets, query
        `hl.funding_payments` through `POST /v1/query`.

        Sign convention: `funding_payment` is signed — positive = received,
        negative = paid. This matches HL `userFunding.delta.usdc` and dYdX
        v4 `fundingPayments.payment`.

        `bucket=1h` sums payments per hour, `bucket=1d` per UTC day, and
        `bucket=raw` returns every payment. Default `1d`. At most 10,000
        rows, newest first.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/address'
        - $ref: '#/components/parameters/market'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: bucket
          in: query
          schema: { type: string, enum: [1h, 1d, raw], default: 1d }
      responses:
        '200':
          description: Funding payments aggregated per the chosen bucket.
          content:
            application/json:
              schema:
                type: object
                properties:
                  address: { type: string }
                  from: { type: integer, format: int64 }
                  to: { type: integer, format: int64 }
                  bucket: { type: string, enum: [1h, 1d, raw] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/UserFundingPayment' }
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/analytics/user-maker-taker:
    get:
      operationId: getUserMakerTaker
      summary: Maker vs taker fill breakdown per market
      description: |
        Per-market breakdown of an address's maker vs taker fills, notional,
        and fees over a time window. The single most diagnostic field for
        market-maker analysis: a high maker share with negative
        `maker_fee` (rebate) is a textbook MM strategy; high taker share
        with positive fees is liquidity-taking flow.

        **Historical caveat:** the underlying `fills.crossed` column was
        added 2026-05-21. Fills ingested before that timestamp default to
        `crossed=0` (maker) until the S3 backfill repopulates them. Until
        backfill completes, queries spanning windows before 2026-05-21 will
        over-count maker fills. The `data` array's window is reflected in
        the response envelope's `from`/`to`.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/address'
        - $ref: '#/components/parameters/market'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
      responses:
        '200':
          description: Per-market maker/taker breakdown + totals row.
          content:
            application/json:
              schema:
                type: object
                properties:
                  address: { type: string }
                  from: { type: integer, format: int64 }
                  to: { type: integer, format: int64 }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/UserMakerTakerBreakdown' }
                  total: { $ref: '#/components/schemas/UserMakerTakerBreakdown' }
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/analytics/user-ledger:
    get:
      operationId: getUserLedger
      summary: Non-fill ledger events for an address
      description: |
        Ledger of events affecting an address's balance other than fills:
        USDC deposits/withdrawals, vault operations, plus the misc-events
        `LedgerUpdate` catch-all (sends, account-class transfers, HYPE
        airdrops, HYPE staking delegate/undelegate, subaccount transfers,
        L1 bridge events). Mirrors HL's `userNonFundingLedgerUpdates`
        envelope shape with a `type` discriminator.

        Sign convention: `amount` is signed — positive = into the account,
        negative = out. Transfers between two addresses (`send`,
        `spot_send`, `internal_transfer`, `sub_account_transfer`) appear in
        both the sender's ledger (negative) and the receiver's (positive).

        Window: `to` defaults to now and `from` to a week before `to`, cut
        to the key's history (24 hours keyless). The misc-events catch-all
        starts 2026-05-21.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/address'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: event_types
          in: query
          description: |
            Comma-separated list of types to include. Supported values:
            `deposit`, `withdrawal`, `vault_deposit`, `vault_withdraw`,
            `vault_transfer_in`, `vault_transfer_out`, `vault_distribute`,
            `vault_commission`, `vault_create`, `send`, `spot_send`,
            `internal_transfer`, `account_class_transfer`, `rewards_claim`,
            `delegate`, `undelegate`, `sub_account_transfer`, `c_deposit`,
            `c_withdraw`, `other`. The filter runs before `limit`.
          schema: { type: string, example: "deposit,withdrawal,rewards_claim" }
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Ledger entries newest-first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  address: { type: string }
                  from: { type: integer, format: int64 }
                  to: { type: integer, format: int64 }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/UserLedgerEntry' }
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  # ─── Cross-wallet ranking endpoints (v0.34+) ───────────────────────────────

  /v1/analytics/leaderboard-pnl:
    get:
      operationId: getPnLLeaderboard
      summary: Global PnL leaderboard (Smart Money)
      description: |
        Every HL wallet ranked by realized PnL, biggest losses, or total
        volume over a chosen window. Replaces 10-cap "tracked addresses"
        of the public HL API — every active address in the window is
        eligible. Wallets with fewer than 5 fills are excluded as noise.

        Expensive over wide windows (~15s cold on 30d); response is
        edge-cached for 60s + SWR 300s. Anonymous-readable.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/source'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: sort_by
          in: query
          schema:
            type: string
            enum: [realized_pnl, loss, volume]
            default: realized_pnl
          description: |
            `realized_pnl` = top winners (DESC). `loss` = biggest losers
            (ASC by signed PnL). `volume` = highest notional traded.
      responses:
        '200':
          description: Ranked wallet list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  from: { type: integer, format: int64 }
                  to: { type: integer, format: int64 }
                  sort_by: { type: string, enum: [realized_pnl, loss, volume] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/PnLLeaderboardRow' }
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/analytics/market-top-wallets:
    get:
      operationId: getMarketTopWallets
      summary: Top wallets in a market
      description: |
        Per-market wallet ranking — ranked traders for one specific
        market over the chosen window. Sort by realized PnL or volume.
        HL's public info API exposes only the top-10 by 24h volume;
        this returns up to 200 ranked by either metric over any window.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/source'
        - name: market
          in: query
          required: true
          schema: { type: string, example: "BTC-PERP" }
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: limit
          in: query
          description: Rows to return, 1 to 200. A larger value returns 200 rows.
          schema: { type: integer, minimum: 1, maximum: 200, default: 25 }
        - name: sort_by
          in: query
          schema:
            type: string
            enum: [volume, realized_pnl]
            default: volume
      responses:
        '200':
          description: Top wallets for the market.
          content:
            application/json:
              schema:
                type: object
                properties:
                  market: { type: string }
                  from: { type: integer, format: int64 }
                  to: { type: integer, format: int64 }
                  sort_by: { type: string, enum: [volume, realized_pnl] }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/MarketTopWalletRow' }
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/markets/snapshot:
    get:
      operationId: getMarketsSnapshot
      summary: Single-call snapshot for all markets
      description: |
        Anchored snapshot blob — one row per active market with current
        mark, 24h-ago mark, 24h change %, latest open interest (USD and
        base), and latest funding. Perps appear while they have a mark
        or trades, HIP-4 outcomes while they traded in the last 24 hours. Replaces N×3 fan-out calls (candles +
        OI + funding per row). Powers the terminal markets table at
        ~50ms p99 from the edge cache.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/source'
        - name: market_class
          in: query
          schema: { type: string, enum: [perp, spot, prediction, option] }
        - name: issuer
          in: query
          description: HIP-3 builder code; empty string for native non-builder only.
          schema: { type: string }
      responses:
        '200':
          description: Snapshot rows.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/MarketSnapshotRow' }
        '500': { $ref: '#/components/responses/InternalError' }

  /v1/book:
    get:
      operationId: getOrderBook
      summary: Live L2 order book of one market
      description: |
        The live L2 order book of one Hyperliquid market: up to 20 price
        levels a side with size and order count, best bid and ask, mid,
        spread in basis points and the USD depth returned on each side.
        Served from Hyperliquid's l2Book, cached up to one second per
        market; a book up to 10 s old is returned with `stale: true` when
        Hyperliquid cannot be reached. Perps, spot, HIP-3 and HIP-4
        markets. No key needed.
      tags: [Market Data]
      parameters:
        - name: market
          in: query
          required: true
          description: Market symbol (BTC-PERP, xyz:NVDA-PERP, HYPE/USDC) or Hyperliquid coin name (BTC, @107).
          schema: { type: string }
        - name: depth
          in: query
          description: Levels a side, 1 to 20.
          schema: { type: integer, minimum: 1, maximum: 20, default: 20 }
        - name: sig_figs
          in: query
          description: Aggregate prices to 2 to 5 significant figures (coarser levels, deeper book). Leave out for full precision.
          schema: { type: integer, minimum: 2, maximum: 5 }
      responses:
        '200':
          description: The order book.
          content:
            application/json:
              schema:
                type: object
                properties:
                  market: { type: string, example: BTC-PERP }
                  coin: { type: string, example: BTC }
                  time: { type: integer, format: int64, description: "Book time, Unix ms" }
                  bids:
                    type: array
                    items:
                      type: object
                      properties:
                        price: { type: string }
                        size: { type: string }
                        orders: { type: integer }
                  asks:
                    type: array
                    items:
                      type: object
                      properties:
                        price: { type: string }
                        size: { type: string }
                        orders: { type: integer }
                  best_bid: { type: string }
                  best_ask: { type: string }
                  mid: { type: number }
                  spread_bps: { type: number }
                  bid_depth_usd: { type: number }
                  ask_depth_usd: { type: number }
                  source: { type: string }
                  stale: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404':
          description: Unknown market.
        '503':
          description: Hyperliquid could not be reached; retry after a few seconds.

  /v1/analytics/wallet-labels:
    get:
      operationId: getWalletLabels
      summary: Precomputed wallet labels (whales, smart money, vaults)
      description: |
        Single-blob catalog of address → label list. Covers four
        categories:

        - **whale** — top 100 wallets by 24h notional volume
        - **smart_money** — top 100 wallets by 30d realized PnL
        - **vault** — every distinct vault address with ops in 365d
        - **vault_leader** — modal commission recipient per vault
          (the operator earning carry)

        Edge-cached for 5 minutes. The full response is ~few KB and is
        designed to be fetched once per session and used to label every
        address in the terminal without per-row API calls.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/source'
        - name: whale_top_n
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: smart_top_n
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        '200':
          description: Wallet labels blob.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WalletLabelsResponse' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/analytics/user-summary:
    get:
      operationId: getUserSummary
      summary: Wallet activity summary (single round-trip)
      description: |
        Fast-path replacement for the N-call wallet X-Ray fan-out. Returns
        aggregate stats for an address over a window: total volume,
        realized PnL, fill count, win rate, maker/taker mix, markets
        touched. Single ClickHouse aggregation.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/source'
        - $ref: '#/components/parameters/address'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
      responses:
        '200':
          description: Wallet summary stats.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserSummary' }
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  /v1/analytics/user-pnl-series:
    get:
      operationId: getUserPnLSeries
      summary: Wallet realized-PnL time series
      description: |
        Bucketed realized PnL time series for one address. Each point is
        the sum of `realized_pnl` over `bucket_ms` ms. Used for the
        wallet X-Ray cumulative PnL chart.
      tags: [Analytics]
      parameters:
        - $ref: '#/components/parameters/source'
        - $ref: '#/components/parameters/address'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: bucket_ms
          in: query
          description: Bucket size in ms. Server clamps so the series has ≤500 buckets.
          schema: { type: integer, format: int64, example: 3600000 }
      responses:
        '200':
          description: Per-bucket realized PnL.
          content:
            application/json:
              schema:
                type: object
                properties:
                  address: { type: string }
                  from: { type: integer, format: int64 }
                  to: { type: integer, format: int64 }
                  bucket_ms: { type: integer, format: int64 }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/UserPnLSeriesPoint' }
        '400': { $ref: '#/components/responses/QueryWindowExceeded' }
        '500': { $ref: '#/components/responses/InternalError' }
        '504': { $ref: '#/components/responses/QueryTimeout' }

  # ─── Trading app (Phase A) ───────────────────────────────────────

  # ─── Strategies (Bet 6 — bundled signing ceremony) ─────────────────────────

  # ─── Cohorts ───────────────────────────────────────────────────────────────

  /v1/cohorts:
    get:
      operationId: listCohorts
      summary: List cohorts
      description: |
        List the six predefined cohorts (`top_pnl_30d`, `high_volume_30d`,
        `whale_fills`, `net_withdrawers_7d`, `liquidation_prone`,
        `vault_whales`) plus any custom cohorts owned by the caller.
        Predefined cohorts are recomputed every 30 minutes and served from
        cache.
      tags: [Cohorts]
      responses:
        '200':
          description: List of cohorts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CohortInfo' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createCohort
      summary: Create custom cohort
      description: |
        Create a custom address cohort. Max 10,000 addresses per cohort.

        **Tier limits:** Free, Explorer, and Builder use predefined cohorts only. Custom cohorts require the Enterprise tier (unlimited).
      tags: [Cohorts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, addresses]
              properties:
                name: { type: string, example: my_whales }
                addresses:
                  type: array
                  items: { type: string }
                  example: ["0xabc123", "0xdef456"]
      responses:
        '201':
          description: Cohort created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CohortInfo' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/cohorts/{name}:
    get:
      operationId: getCohortAddresses
      summary: Get cohort addresses
      description: Get all addresses in a cohort.
      tags: [Cohorts]
      parameters:
        - name: name
          in: path
          required: true
          schema: { type: string }
          example: top_pnl_30d
      responses:
        '200':
          description: Cohort addresses.
          content:
            application/json:
              schema:
                type: object
                properties:
                  addresses:
                    type: array
                    items:
                      type: object
                      properties:
                        address: { type: string }
                        label: { type: string, nullable: true }
                  fetched_at: { type: integer, format: int64 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Cohort not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      operationId: deleteCohort
      summary: Delete custom cohort
      description: Delete a custom cohort. Predefined cohorts cannot be deleted.
      tags: [Cohorts]
      parameters:
        - name: name
          in: path
          required: true
          schema: { type: string }
      responses:
        '204': { description: Cohort deleted. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/markets/{id}/risk:
    get:
      operationId: getMarketRisk
      summary: Per-market risk card
      description: |
        Deployer, oracle source, observed 30-day mark-vs-oracle divergence,
        and a suggested max leverage with the formula disclosed. Built for
        HIP-3 builder markets, whose deployer-run oracles can detach from
        the mark (the 28 July 2026 SKHYNIX event); native markets return
        the market maximum with `oracle_source: hyperliquid`.

        `max_divergence_bps_30d` is null until paired mark/oracle samples
        exist (the oracle series is stored since 2026-09-04). The
        suggestion is a sizing hint, not advice.
      tags: [Market Data]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            minimum: 0
          description: Registry market_id (see /v1/markets).
      responses:
        "200":
          description: Risk card for the market.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MarketRisk"
              example:
                market_id: 110042
                display_symbol: "xyz:SKHX-PERP"
                market_class: perp
                deployer: xyz
                oracle_source: "deployer (xyz)"
                max_leverage: 20
                max_divergence_bps_30d: 812.4
                divergence_window_days: 30
                suggested_max_leverage: 3
                formula: "suggested_max_leverage = min(market max leverage, floor(2500 / max_divergence_bps_30d)); with no divergence history: HIP-3 markets 5, native markets the market max. Not advice - sizing is the primary protection, a wick can beat any reactive guard."
        "404":
          description: Unknown market id.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /v1/markets/{id}/constraints:
    get:
      operationId: getMarketConstraints
      summary: Per-market trading constraints
      description: |
        The constraints nobody publishes in one place: the deployer-set
        open-interest cap with current OI and utilisation (HIP-3 markets;
        native markets have no published cap but still report
        `at_oi_cap`), max leverage and margin tiers, tick and lot rules,
        deployer and oracle source, and the market's recent lifecycle
        events (see /v1/markets/events). Caps and tiers come from
        Hyperliquid's public info endpoints through server-side caches —
        never a per-request upstream call.
      tags: [Market Data]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            minimum: 0
          description: Registry market_id (see /v1/markets).
      responses:
        "200":
          description: Constraints for the market.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MarketConstraints"
              example:
                market_id: 110001
                display_symbol: "xyz:AAPL-PERP"
                market_class: perp
                deployer: xyz
                oracle_source: "deployer (xyz)"
                max_leverage: 20
                margin_tiers:
                  - lower_bound_usd: "0.0"
                    max_leverage: 20
                  - lower_bound_usd: "1000000.0"
                    max_leverage: 10
                sz_decimals: 2
                price_sig_figs: 5
                max_price_decimals: 4
                min_notional_usd: 10
                oi_cap_usd: 200000000
                open_interest_usd: 150000000
                oi_utilization: 0.75
                at_oi_cap: false
                lifecycle_events: []
                as_of_ms: 1788700000000
        "404":
          description: Unknown market id.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
  /v1/wallets/{address}/borrow:
    get:
      operationId: getWalletBorrow
      summary: Wallet borrow and interest state
      description: |
        The wallet's borrow/lend picture under Hyperliquid's
        portfolio-margin borrow feature: per-token positions (passed
        through raw from HL's `borrowLendUserState` — the per-token shape
        is not publicly documented and is never reshaped), overall health,
        and the trailing-30-day ledger entries whose type mentions
        borrow / interest / repay / lend / supply, with the matched type
        names disclosed. Wallets that never borrowed return empty arrays.
        Cached 60 seconds per wallet.
      tags: [Analytics]
      security:
        - bearerAuth: []
      parameters:
        - name: address
          in: path
          required: true
          schema:
            type: string
            pattern: "^0x[0-9a-fA-F]{40}$"
      responses:
        "200":
          description: Borrow state for the wallet.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WalletBorrow"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "502":
          description: Hyperliquid upstream unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /v1/wallets/{address}/health:
    get:
      operationId: getWalletHealth
      summary: Portfolio-margin health simulator
      description: |
        The wallet's cross-margin health picture — health factor (account
        value / maintenance margin, marginal-tier), margin available, and
        per-leg liquidation prices — plus an optional what-if: shock one
        or more assets by a percentage and see the projected health
        factor, which leg breaks first, and each leg's liquidation price.
        The solver reproduces Hyperliquid's own per-position
        liquidationPx (validated on a live 56-position account, 54/56
        within 0.01%). Cross positions on the default clearinghouse only;
        isolated legs and HIP-3 dex positions margin separately.
      tags: [Analytics]
      security:
        - bearerAuth: []
      parameters:
        - name: address
          in: path
          required: true
          schema:
            type: string
            pattern: "^0x[0-9a-fA-F]{40}$"
        - name: shock
          in: query
          required: false
          schema:
            type: string
          description: "Comma-separated COIN:PCT pairs, e.g. BTC:-20,ETH:-10 (each in [-95, 95])."
      responses:
        "200":
          description: Current (and shocked, when requested) health picture.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WalletHealth"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "502":
          description: Hyperliquid upstream unavailable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
  /v1/markets/events:
    get:
      operationId: listMarketEvents
      summary: Market lifecycle events
      description: |
        Public append-only log of market lifecycle changes on Hyperliquid:
        `delisted` (a market disappeared from a complete registry walk),
        `rebased` (a perp changed its contract size / szDecimals), and
        reserved kinds `oi_cap_changed` and `gated`. The strategy engine
        consumes the same table to auto-pause strategies on affected
        markets. Page forward with `since_id` (last seen event id).
      tags: [Market Data]
      security: []  # public endpoint, no auth required
      parameters:
        - name: since_id
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
          description: Return only events with id greater than this.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
      responses:
        "200":
          description: Events with id > since_id, oldest first.
          content:
            application/json:
              schema:
                type: object
                required: [events]
                properties:
                  events:
                    type: array
                    items:
                      $ref: "#/components/schemas/MarketEvent"
              example:
                events:
                  - id: 12
                    venue: hyperliquid
                    market_id: 110042
                    display_symbol: "xyz:SKHX-PERP"
                    kind: delisted
                    details: {}
                    created_at: "2026-09-05T10:15:00Z"
        "400":
          $ref: "#/components/responses/BadRequest"
  /v1/alerts:
    get:
      operationId: listAlerts
      summary: List liquidation alerts
      description: |
        The liquidation alerts on this key's account, with their delivery
        state, and how many the tier allows. Destinations are shown without
        their secret part (a Discord or Slack webhook URL is a credential).
      tags: [Alerts]
      responses:
        '200':
          description: The account's alerts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  alerts:
                    type: array
                    items: { $ref: '#/components/schemas/LiquidationAlert' }
                  limit: { type: integer, description: "Alerts this tier allows: free 1, Explorer 5, Builder 20, Enterprise 100.", example: 5 }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createAlert
      summary: Create a liquidation alert
      description: |
        Ironflow posts each Hyperliquid liquidation that matches the filter to
        `url`, usually within a few seconds of the chain: an HTTPS webhook (a
        signed JSON body), a Discord channel webhook or a Slack incoming
        webhook (a chat message). For Telegram send `telegram_chat` instead
        of `url`, and add the bot named in the response's `setup` to that
        chat. One alert sends at most one message every 5
        seconds, listing what it matched since the last one, so a cascade
        arrives as a few messages rather than hundreds.

        Partial fills of one liquidated position in one transaction count as
        one liquidation, and `min_usd` applies to their total.

        **Tier limits:** free 1 alert, Explorer 5, Builder 20, Enterprise 100.
        Keyless requests cannot create alerts. On the free tier, Discord,
        Slack and Telegram messages end with a one-line Ironflow link.

        For a webhook the response carries `signing_secret` once: every
        delivery has an `X-Ironflow-Signature: t=<unix seconds>,v1=<hex>`
        header, the HMAC-SHA256 of `<t>.<raw body>` with that secret.
      tags: [Alerts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                telegram_chat:
                  type: string
                  description: "A Telegram chat instead of `url`: a public @channel or group name, or a numeric chat id (groups and channels start with -100). The bot named in `setup` must be in the chat (in a channel, as an admin that can post)."
                  example: "@my_liquidations"
                url:
                  type: string
                  description: "Where to post: `https://discord.com/api/webhooks/...`, `https://hooks.slack.com/services/...` or any public HTTPS URL (port 443 or 8443). Required unless `telegram_chat` is set."
                  example: https://discord.com/api/webhooks/123/abc
                name: { type: string, maxLength: 80, description: "Shown in the message title.", example: BTC and ETH longs }
                markets:
                  description: "Markets to watch: symbols or Hyperliquid coin names, as a list or a comma-separated string (at most 50). Omit for every market."
                  oneOf:
                    - type: array
                      items: { type: string }
                    - type: string
                  example: ["BTC", "ETH-PERP", "xyz:NVDA"]
                side:
                  type: string
                  enum: [any, long, short]
                  default: any
                  description: "The liquidated position: `long` means longs were liquidated."
                min_usd: { type: number, minimum: 0, default: 0, description: "Smallest liquidation to report, in USD.", example: 100000 }
                address: { type: string, description: "Only this wallet's liquidations.", example: "0xabc0000000000000000000000000000000000001" }
      responses:
        '201':
          description: Alert created. `signing_secret` appears only here, and only for webhooks.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LiquidationAlert' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/alerts/{id}:
    delete:
      operationId: deleteAlert
      summary: Delete a liquidation alert
      description: Stops the alert and removes it from the account; nothing more is sent to its destination.
      tags: [Alerts]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '204': { description: Deleted. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: No alert with that id on this account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/alerts/{id}/test:
    post:
      operationId: testAlert
      summary: Send a test message
      description: |
        Sends a sample liquidation (marked as a test) to the alert's
        destination now and reports whether it arrived. A delivered test
        switches back on an alert that was disabled after 20 failed
        deliveries in a row. One test per alert every 10 seconds.
      tags: [Alerts]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: The result. A destination that refused the message is still a 200, with `delivered` false and its answer in `error`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivered: { type: boolean }
                  status: { type: string, enum: [active, disabled] }
                  error: { type: string, example: "destination answered HTTP 404: Unknown Webhook" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: No alert with that id on this account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /rpc/{key}/info:
    post:
      operationId: rpcInfo
      summary: Hyperliquid /info on your plan's limits
      description: |
        The API key as a Hyperliquid RPC: this URL takes the same JSON body
        as `https://api.hyperliquid.xyz/info` and returns the same JSON, so
        pointing a Hyperliquid SDK's `base_url` at
        `https://api.ironflow.sh/rpc/<key>` is a one-line change. Each call
        counts as one request against the key's plan limits. There is no
        keyless access. Treat this URL as a secret; the key is never stored
        in logs.

        Reads served from Ironflow's own node: `clearinghouseState`,
        `spotClearinghouseState`, `openOrders`, `frontendOpenOrders`, `meta`,
        `spotMeta`, `userAbstraction`. `allMids` (main dex and spot), `l2Book`
        (`nSigFigs` 2 to 5, no `mantissa`) and `candleSnapshot` (at most the
        latest 1,000 candles, intervals below 1w) are served from the API's
        own data. Any other `type` is refused with a 400 listing these.

        `POST /rpc/<key>/exchange` is not served: it answers 400
        (`EXCHANGE_NOT_SERVED`), and orders go directly to Hyperliquid.

        An error body carries Hyperliquid's top-level `code` and `msg` next
        to the usual `error` object, so the Python SDK can raise it.
      tags: [Hyperliquid RPC]
      security: []
      parameters:
        - name: key
          in: path
          required: true
          description: Your Ironflow API key (`if_...`). Keep the URL private.
          schema: { type: string, example: if_your_key }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              additionalProperties: true
              properties:
                type:
                  type: string
                  enum: [clearinghouseState, spotClearinghouseState, openOrders, frontendOpenOrders, meta, spotMeta, userAbstraction, allMids, l2Book, candleSnapshot]
                  description: The Hyperliquid /info request type.
            example:
              type: clearinghouseState
              user: "0x0000000000000000000000000000000000000000"
      responses:
        '200':
          description: The JSON Hyperliquid answers for this body.
          content:
            application/json:
              schema: {}
              example:
                marginSummary: { accountValue: "0.0", totalNtlPos: "0.0", totalRawUsd: "0.0", totalMarginUsed: "0.0" }
                assetPositions: []
        '400':
          description: A `type` this endpoint does not serve (the message lists the supported ones), or a body it cannot read.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: INVALID_PARAMETER
                  message: 'type "portfolio" is not served here. Supported types: clearinghouseState, spotClearinghouseState, openOrders, frontendOpenOrders, meta, spotMeta, userAbstraction, allMids, l2Book, candleSnapshot'
                code: INVALID_PARAMETER
                msg: 'type "portfolio" is not served here. Supported types: clearinghouseState, spotClearinghouseState, openOrders, frontendOpenOrders, meta, spotMeta, userAbstraction, allMids, l2Book, candleSnapshot'
        '401':
          description: The key in the URL is invalid (`INVALID_API_KEY`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: INVALID_API_KEY
                  message: API key not found or deactivated
                code: INVALID_API_KEY
                msg: API key not found or deactivated
        '429':
          description: Over the key's requests per minute (`RATE_LIMIT_EXCEEDED`), with `Retry-After` and the usual `upgrade` object.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: RATE_LIMIT_EXCEEDED
                  message: rate limit exceeded
                code: RATE_LIMIT_EXCEEDED
                msg: rate limit exceeded
        '503':
          description: Our node is behind or restarting, so no state is served (`NODE_UNAVAILABLE`); retry after `Retry-After` seconds.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema: { type: integer }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: NODE_UNAVAILABLE
                  message: our Hyperliquid node is behind right now, so we refuse to serve stale state; retry shortly
                code: NODE_UNAVAILABLE
                msg: our Hyperliquid node is behind right now, so we refuse to serve stale state; retry shortly

  /v1/ironbox:
    get:
      operationId: listIronboxes
      summary: List your Ironboxes
      description: The Ironboxes of the calling account, newest first. Deleted ones are left out. A box URL is never shown here. Without a key the list is empty and `message` says how to make a trial box.
      tags: [Ironbox]
      security:
        - bearerAuth: []
      responses:
        '200':
          description: The account's Ironboxes.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Ironbox' }
        '401': { $ref: '#/components/responses/IronboxKeyRequired' }
    post:
      operationId: createIronbox
      summary: Create an Ironbox
      description: |
        `mode: paper` (the default) creates your own simulated Hyperliquid
        account. It is `running` at once. Your agent trades it with the
        official Hyperliquid SDK by setting `base_url` to the `base_url` in
        this response, which is shown only here and on `rotate`. No code runs
        on our servers, and it does not place real orders. Fees, margin,
        funding and liquidation are simulated on main perp markets; see the
        guide for the rules.

        Every field is optional. A paper box needs any API key, a free one
        included (no key: `POST /v1/keys` with `{"email": ...}` sends one).
        Paper boxes per account: Free 3, Explorer 5, Builder 10, Enterprise
        25.

        With no key at all, `mode: paper` (the default) creates a trial box:
        the answer has `base_url`, `trial: true` and `expires_at`. It runs for
        24 hours at most on free limits, and ends sooner after 2 hours with no
        requests and no strategy running; it is then removed with its orders
        and fills. 3 at once and 10 a day per caller. A free key keeps a
        box. Browsers can call this from any origin.

        Every paper box can run strategies with no client connected: see
        `POST /box/{secret}/strategies`.
      tags: [Ironbox]
      security:
        - bearerAuth: []
        - {}
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 64, description: "Your name for the box.", example: grid-bot }
                size: { type: string, enum: [small, medium, large], default: small }
                mode: { type: string, enum: [paper], default: paper, description: "`paper`: your own simulated Hyperliquid account. Ironbox is paper only." }
                runtime: { type: string, enum: [python, node], default: python }
                markets:
                  type: array
                  items: { type: string, enum: [perps, outcomes, spot] }
                  default: [perps]
                  description: "What the agent trades: perps (native and HIP-3), outcomes (HIP-4 prediction markets), spot."
                note: { type: string, maxLength: 500, description: "Anything we should know: strategy, markets, volume." }
                wallet: { type: string, description: "Optional 0x address that binds a paper box to a wallet before its first signed action. Use a fresh throwaway wallet for paper, never a funded one.", example: "0x19E7E376E7C213B7E7e7e46cc70A5dD086DAff2A" }
            example:
              name: grid-bot
              size: small
              mode: paper
              runtime: python
              markets: [perps, outcomes]
      responses:
        '201':
          description: The paper box. It is `running` and includes `base_url` and `snippet` (shown only here and on `rotate`).
          headers:
            Location:
              description: The box's `url`.
              schema: { type: string, format: uri }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ironbox' }
        '400':
          description: A field has a value that is not allowed (`INVALID_PARAMETER`, naming the allowed values) or the body is not JSON (`INVALID_BODY`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: INVALID_PARAMETER
                  message: 'size must be one of small, medium, large (got "xl")'
        '401':
          description: "The `Authorization` header holds a key that is not valid (`INVALID_API_KEY`). A paper box needs no key: send none for a trial box."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: INVALID_API_KEY
                  message: 'API key not found or deactivated. Free keys: https://ironflow.sh/key?utm_source=api, or POST https://api.ironflow.sh/v1/keys with {"email": "you@example.com"} (the key is emailed to that address, never returned, so a person has to open the email)'
        '429':
          description: The account already has the paper boxes its plan allows, or a keyless caller already has 3 trial boxes running or made 10 in the last 24 hours (`IRONBOX_LIMIT`; delete one first, or wait for a trial box to end), made too many creates this hour (`RATE_LIMITED`), or paper boxes or trial boxes are at capacity (`IRONBOX_CAPACITY`, retry later).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: IRONBOX_LIMIT
                  message: "this account already has 3 paper boxes, the most on this plan. Delete one first: DELETE https://api.ironflow.sh/v1/ironbox/{id} (GET https://api.ironflow.sh/v1/ironbox lists them)"
        '503':
          description: The paper engine is not reachable (`IRONBOX_UNAVAILABLE`). Nothing was created; try again in a minute.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/ironbox/{id}:
    get:
      operationId: getIronbox
      summary: Get an Ironbox
      description: One of the account's Ironboxes, with its state. A paper box also has `stats` (equity, profit, fees, funding, positions, fills, data gaps). The box URL is not shown here.
      tags: [Ironbox]
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, pattern: '^ibx_[0-9a-f]{12}$' }, example: ibx_3f9a0c7e12d4 }
      responses:
        '200':
          description: The Ironbox.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ironbox' }
        '401': { $ref: '#/components/responses/IronboxKeyRequired' }
        '404':
          description: No Ironbox with that id on this key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      operationId: deleteIronbox
      summary: Delete an Ironbox
      description: Deletes the Ironbox and answers with it, in state `deleted`. It no longer counts toward the key's 3.
      tags: [Ironbox]
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, pattern: '^ibx_[0-9a-f]{12}$' }, example: ibx_3f9a0c7e12d4 }
      responses:
        '200':
          description: The Ironbox, in state `deleted`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ironbox' }
        '401': { $ref: '#/components/responses/IronboxKeyRequired' }
        '404':
          description: No Ironbox with that id on this key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/ironbox/{id}/reset:
    post:
      operationId: resetIronbox
      summary: Reset a paper box
      description: Back to the starting balance with no positions, orders or fills. The wallet binding stays. Paper boxes only.
      tags: [Ironbox]
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, pattern: '^ibx_[0-9a-f]{12}$' }, example: ibx_3f9a0c7e12d4 }
      responses:
        '200':
          description: The box, reset.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ironbox' }
        '400':
          description: Not a paper box (`NOT_A_PAPER_BOX`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/IronboxKeyRequired' }
        '404':
          description: No Ironbox with that id on this account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: The paper engine is not reachable (`IRONBOX_UNAVAILABLE`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/ironbox/{id}/rotate:
    post:
      operationId: rotateIronbox
      summary: Get a new URL for a paper box
      description: |
        Replaces the box URL. The new `base_url` and `snippet` are in this
        response only. Send `{"unbind": true}` to also clear the wallet
        binding. A paper box created before box URLs existed gets its first
        URL here.
      tags: [Ironbox]
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, pattern: '^ibx_[0-9a-f]{12}$' }, example: ibx_3f9a0c7e12d4 }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                unbind: { type: boolean, default: false, description: "Clear the wallet binding too." }
      responses:
        '200':
          description: The box with its new `base_url`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Ironbox' }
        '400':
          description: Not a paper box (`NOT_A_PAPER_BOX`) or the body is not JSON (`INVALID_BODY`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/IronboxKeyRequired' }
        '404':
          description: No Ironbox with that id on this account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: The paper engine is not reachable (`IRONBOX_UNAVAILABLE`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/ironbox/{id}/promote:
    post:
      operationId: promoteIronbox
      summary: Get a paper box's report
      description: |
        Never changes the box. No plan is needed. It reports how the paper
        account did. Ironbox is paper only and never places real orders.
      tags: [Ironbox]
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, pattern: '^ibx_[0-9a-f]{12}$' }, example: ibx_3f9a0c7e12d4 }
      responses:
        '200':
          description: What is true today, with the paper report.
          content:
            application/json:
              schema:
                type: object
                required: [id, mode, promoted, live_orders_on_ironflow, message]
                properties:
                  id: { type: string }
                  mode: { type: string, enum: [paper] }
                  promoted: { type: boolean }
                  live_orders_on_ironflow: { type: boolean }
                  message: { type: string }
                  go_live_on_hyperliquid:
                    type: object
                    description: Kept for older clients. Ironbox is paper only.
                    properties:
                      base_url: { type: string, format: uri }
                      wallet: { type: string }
                  paper_report:
                    type: object
                    properties:
                      days_active: { type: integer }
                      orders: { type: integer }
                      fills: { type: integer }
                      fees_usd: { type: number }
                      funding_usd: { type: number }
                      equity_usd: { type: number }
                      max_drawdown_pct: { type: number }
                      rejected_orders: { type: integer }
                      data_gap_seconds: { type: integer }
              example:
                id: ibx_3f9a0c7e12d4
                mode: paper
                promoted: false
                live_orders_on_ironflow: false
                message: This box is paper. Ironbox never places real orders.
                paper_report:
                  days_active: 14
                  orders: 812
                  fills: 455
                  fees_usd: 42.1
                  funding_usd: -6.3
                  equity_usd: 10312.4
                  max_drawdown_pct: 3.2
                  rejected_orders: 11
                  data_gap_seconds: 37
        '400':
          description: Not a paper box (`NOT_A_PAPER_BOX`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/IronboxKeyRequired' }
        '404':
          description: No Ironbox with that id on this account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /box/{secret}/strategies:
    parameters:
      - $ref: '#/components/parameters/BoxSecret'
    post:
      operationId: startBoxStrategy
      summary: Start a strategy on a paper box
      description: |
        Runs on the box around the clock with no client connected. Paper only:
        no strategy here ever touches real Hyperliquid, and no customer code
        runs, only these parameters. Authorised by the box secret, so a keyless
        trial box can use it. Orders go through the box's normal validation,
        matching, fees, margin and liquidation, without a signature.

        `kind` is `breakout`, `grid`, `dca` or `bot`. Every kind needs `market` and
        `max_loss_usd`: when the strategy's net loss reaches it, the strategy
        cancels its orders, closes its position and stops with reason
        `max_loss`. At most 3 strategies running per box and one per market.
        Unknown fields are refused.

        `bot` runs a bot.yaml (the Ironbox bot file, schema at
        https://ironflow.sh/schema/ironbox-bot.schema.json) as JSON in `bot`,
        with the same conditions, exits, leverage and limits as an Ironbox
        backtest. It takes `market` and leverage from the file, and
        `max_loss_usd` defaults to 10000. `warmup` may carry recent history so
        the rule can judge at once; without it the rule waits until live data
        covers its windows. `warmup` is used at the start and never stored. A
        rule with `liquidations` needs the server's live liquidation feed
        (409 `bot_needs_liquidations` without it). The answer's `watch_url` is
        a read-only page of the run, safe to share.

        `breakout` enters when the mark leaves the channel of the last
        `lookback_bars` closes and exits when it leaves the channel of
        `exit_bars`. It keeps running after an exit. `grid` places a ladder of
        `grids` rungs between `lower_px` and `upper_px` (`size_usd` in total) as
        maker orders, and takes each rung's profit one level away. `dca` buys
        `tranche_usd` at once and again on each `dip_pct` move or
        `interval_minutes`, up to `budget_usd`, optionally closing at
        `take_profit_pct`, and starting again with `repeat`.
      tags: [Ironbox]
      security:
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StrategyParams' }
            examples:
              dca:
                value: { kind: dca, market: BTC, tranche_usd: 100, budget_usd: 500, dip_pct: 2, take_profit_pct: 3, max_loss_usd: 100 }
              grid:
                value: { kind: grid, market: ETH, lower_px: "2900", upper_px: "3100", grids: 10, size_usd: 1000, max_loss_usd: 100 }
              breakout:
                value: { kind: breakout, market: SOL, size_usd: 500, lookback_bars: 20, max_loss_usd: 50 }
              bot:
                value:
                  kind: bot
                  bot:
                    name: btc-flush
                    market: BTC
                    when: { all: [ { liquidations: { side: long, min_usd: 2000000, minutes: 10 } } ] }
                    action: { buy_usd: 200 }
                    exits: { stop_pct: 2, take_pct: 3, max_hours: 24 }
                    risk: { max_usd: 200, daily_stop_pct: 5 }
                  warmup: { candles: [ [1762603200000, 76010, 76240, 75880, 76120, 512.3] ], liquidations: [], liquidations_from: 1762606200000 }
      responses:
        '201':
          description: The strategy, running.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StrategyAnswer' }
        '400':
          description: "`invalid_strategy`: a field is missing, out of range or unknown, the market is not live, or `mode` is anything but `paper`. `invalid_bot`: the bot file or its warmup is not valid; `msg` names the field."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BoxError' }
              example: { code: 400, msg: "max_loss_usd is required, between 0 and 1000000: it stops the strategy when reached", error: invalid_strategy }
        '404': { $ref: '#/components/responses/BoxNotFound' }
        '409':
          description: "`strategy_limit` (3 already running), `strategy_market_busy` (one per market), `box_liquidated` (reset it first), `leverage_refused`, or `bot_needs_liquidations` (a bot with a liquidation rule on a server without the live feed)."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BoxError' }
        '429':
          description: "`strategy_capacity`: the engine's strategy budget is full, try after `Retry-After`. Also the box and address request limits."
          headers:
            Retry-After: { description: Seconds to wait., schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BoxError' }
        '503':
          description: "`strategies_unavailable` or `engine_not_ready`."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BoxError' }
    get:
      operationId: listBoxStrategies
      summary: List a paper box's strategies
      description: Running ones and the last 20 stopped ones, newest first.
      tags: [Ironbox]
      security:
        - {}
      responses:
        '200':
          description: The box's strategies.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StrategyList' }
        '404': { $ref: '#/components/responses/BoxNotFound' }
    delete:
      operationId: stopAllBoxStrategies
      summary: Stop every strategy on a paper box
      description: Stops every running strategy on the box, like stopping each one. Those with a position to close answer `stopping` until the close fills.
      tags: [Ironbox]
      security:
        - {}
      parameters:
        - $ref: '#/components/parameters/ClosePosition'
      responses:
        '200':
          description: The box's strategies after the stop.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StrategyList' }
        '404': { $ref: '#/components/responses/BoxNotFound' }

  /box/{secret}/strategies/{strategyId}:
    parameters:
      - $ref: '#/components/parameters/BoxSecret'
      - { name: strategyId, in: path, required: true, schema: { type: string, pattern: '^strat_[0-9a-f]{16}$' }, example: strat_9f2c4a7e1b3d5c60 }
    get:
      operationId: getBoxStrategy
      summary: Get one strategy
      description: One strategy by id, with its parameters, status, stop reason and result.
      tags: [Ironbox]
      security:
        - {}
      responses:
        '200':
          description: The strategy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StrategyAnswer' }
        '404': { $ref: '#/components/responses/BoxNotFound' }
    delete:
      operationId: stopBoxStrategy
      summary: Stop a strategy
      description: |
        Cancels its orders and closes its position at the book, unless
        `close_position=false`. With a position to close, the answer says
        `stopping` and the strategy is `stopped` once the close fills, normally
        within two seconds.
      tags: [Ironbox]
      security:
        - {}
      parameters:
        - $ref: '#/components/parameters/ClosePosition'
      responses:
        '200':
          description: The strategy, `stopping` or `stopped`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StrategyAnswer' }
        '404': { $ref: '#/components/responses/BoxNotFound' }

  /box/watch/{watchId}:
    parameters:
      - { name: watchId, in: path, required: true, schema: { type: string, pattern: '^w_[0-9a-f]{32}$' }, example: w_3f9a0c7e12d4b5a6c7d8e9f0a1b2c3d4 }
    get:
      operationId: watchBoxStrategy
      summary: Watch one strategy, read only
      description: |
        The strategy behind a `watch_url`: status, parameters, result and its
        last 50 feed events. No box secret is needed or shown, so the link can
        be shared. 404 once the strategy is gone (its box was removed, or 20
        newer strategies stopped after it). Unknown ids count against the same
        per-address limit as unknown box URLs.
      tags: [Ironbox]
      security:
        - {}
      responses:
        '200':
          description: The strategy and its recent feed.
          content:
            application/json:
              schema:
                type: object
                required: [paper, strategy, activity, trial, expires_at]
                properties:
                  paper: { type: boolean, enum: [true] }
                  strategy: { $ref: '#/components/schemas/Strategy' }
                  activity: { type: array, items: { type: object }, description: "This strategy's events, oldest first, as GET /box/{secret}/activity has them." }
                  trial: { type: boolean }
                  expires_at: { type: [integer, 'null'], format: int64, description: "When a trial box, and the run with it, ends." }
        '404':
          description: No such run.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BoxError' }
        '429':
          description: Too many unknown ids from this address.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BoxError' }

  /box/{secret}/activity:
    parameters:
      - $ref: '#/components/parameters/BoxSecret'
    get:
      operationId: getBoxActivity
      summary: Watch what a paper box does
      description: |
        Real events only, oldest first, plus the account summary. Call it with
        no `after` for the latest events, then pass `next_cursor` as `after`
        each time. `gap: true` means the cursor is older than what the box still
        holds (it keeps the last 200 events), so some events were missed.
        Polling counts as traffic and keeps a trial box from going idle.
      tags: [Ironbox]
      security:
        - {}
      parameters:
        - { name: after, in: query, required: false, schema: { type: integer, format: int64, minimum: 0 }, description: "`next_cursor` of the previous answer." }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 200, default: 100 } }
      responses:
        '200':
          description: Events and account summary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BoxActivity' }
              example:
                events:
                  - { seq: 1, time: 1762606400000, kind: strategy_started, strategy_id: strat_9f2c4a7e1b3d5c60, data: { kind: dca, market: BTC, params: { kind: dca, market: BTC, side: long, leverage: 3, max_loss_usd: 100, tranche_usd: 100, budget_usd: 500, dip_pct: 2 } } }
                  - { seq: 2, time: 1762606401000, kind: decision, strategy_id: strat_9f2c4a7e1b3d5c60, data: { action: buy, reason_code: dca_tranche, reason: first tranche } }
                  - { seq: 3, time: 1762606401500, kind: fill, strategy_id: strat_9f2c4a7e1b3d5c60, data: { tid: 1, oid: 1, coin: BTC, side: buy, px: "76012.0", sz: "0.00132", fee_usd: 0.045, closed_pnl_usd: 0, dir: Open Long, crossed: true, liquidation: false } }
                  - { seq: 4, time: 1762606401500, kind: order, strategy_id: strat_9f2c4a7e1b3d5c60, data: { coin: BTC, side: buy, px: "77520.0", sz: "0.00132", tif: Ioc, reduce_only: false, status: filled, oid: 1, avg_px: "76012.0" } }
                next_cursor: 4
                has_more: false
                gap: false
                account: { equity_usd: 9999.82, start_usd: 10000, pnl_usd: -0.18, fees_usd: 0.045, funding_usd: 0, max_drawdown_pct: 0.0, data_gap_seconds: 0, liquidated: false, open_orders: 0, positions: [ { coin: BTC, szi: "0.00132", entry_px: "76012.0", unrealized_pnl: "-0.13", leverage: 3 } ] }
                running_strategies: 1
                expires_at: 1762692800000
                paper: true
        '400':
          description: "`invalid_strategy`: `after` or `limit` is not valid."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BoxError' }
        '404': { $ref: '#/components/responses/BoxNotFound' }

  /box/{secret}/promote:
    parameters:
      - $ref: '#/components/parameters/BoxSecret'
    post:
      operationId: promoteBoxBySecret
      summary: Get a paper box's report with the box URL
      description: |
        The same answer as `POST /v1/ironbox/{id}/promote`, authorised by the
        box secret, so a keyless trial box can use it. It never changes the
        box: Ironbox is paper only and never places real orders.
        On a trial box `keep_box` says what a free key gives: a new box with no
        24-hour limit. It does not move the trial box to an account.
      tags: [Ironbox]
      security:
        - {}
      responses:
        '200':
          description: What is true today, with the paper report.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PromoteReport' }
              example:
                id: ibx_3f9a0c7e12d4
                mode: paper
                promoted: false
                live_orders_on_ironflow: false
                message: This box is paper. Ironbox never places real orders.
                paper_report: { days_active: 1, orders: 12, fills: 9, fees_usd: 0.41, funding_usd: -0.02, equity_usd: 10004.2, max_drawdown_pct: 0.3, rejected_orders: 0, data_gap_seconds: 0 }
                keep_box: "A free key keeps a box past 24 hours, as a new box: https://ironflow.sh/key?utm_source=api"
        '404': { $ref: '#/components/responses/BoxNotFound' }

  /v1/box/data:
    get:
      operationId: getBoxDataWindow
      summary: See which days a box can read
      description: |
        Box data is recent market data for backtests inside an Ironbox, read by
        the `ironbox` command in the box. It is not a bulk download: one perp a
        request, whole UTC days up to yesterday, no wallet or address column.
        A box with no key, or a free key, reads the last 90 days; a paid plan
        reads its own history window. Older days are in the free public files,
        published 90 days after the data date.

        Send the box's paper secret as `X-Ironbox-Token` (the `ibxs_...` part of
        its `base_url`), or an API key. Requests are counted per box: 60 an hour.
      tags: [Ironbox]
      security:
        - IronboxToken: []
        - bearerAuth: []
      responses:
        '200':
          description: The days this caller can read, the request limits and the datasets with their columns.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BoxDataWindow' }
              example:
                from: "2026-08-12"
                to: "2026-11-09"
                history_days: 90
                max_days_per_request: 92
                requests_per_hour: 60
                datasets:
                  - name: funding
                    description: "One hourly funding settlement: the last rate seen in the hour, stamped 50 ms after the hour ends, as Hyperliquid settles it."
                    columns:
                      - { name: timestamp, type: "timestamp[ms, UTC]" }
                      - { name: market, type: string }
                      - { name: funding_rate, type: double }
                note: "For backtests inside an Ironbox: one market a request, up to 92 days, 60 requests an hour. Older days are in the free public files, 90 days behind: https://huggingface.co/ironflow?utm_source=api"
        '401': { $ref: '#/components/responses/BoxDataUnauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/box/data/{dataset}:
    parameters:
      - name: dataset
        in: path
        required: true
        description: "A dataset from GET /v1/box/data. The columns match the public file of the same name; flow-1h (hourly USD volume and taker flow) has no public file."
        schema: { type: string, enum: [candles-1h, liquidations, funding, open-interest, flow-1h] }
    get:
      operationId: getBoxData
      summary: Read one market's recent days as Parquet
      description: |
        One perp's rows over whole UTC days, as a Parquet file. `to` stops at
        yesterday and `from` at the start of the caller's window: the days
        served are in `X-Ironbox-Data-From` and `X-Ironbox-Data-To`. At most 92
        days a request. Liquidations are positions (the fills of one wallet in
        one block), with no wallet column. `flow-1h` is one row per hour with
        trades: `volume_usd`, `taker_buy_usd` (the buyer crossed the spread),
        `taker_sell_usd` (the rest) and `trades`.

        Files for days older than three days are kept a day (`Cache-Control`);
        send the `ETag` back as `If-None-Match` for a 304.
      tags: [Ironbox]
      security:
        - IronboxToken: []
        - bearerAuth: []
      parameters:
        - { name: market, in: query, required: true, schema: { type: string, example: BTC-PERP }, description: "One Hyperliquid perp: BTC-PERP, xyz:NVDA-PERP." }
        - { name: from, in: query, required: false, schema: { type: string, format: date, example: "2026-08-12" }, description: "First UTC day. Default: as far back as one request reaches." }
        - { name: to, in: query, required: false, schema: { type: string, format: date, example: "2026-11-09" }, description: "Last UTC day. Default and latest: yesterday." }
        - { name: If-None-Match, in: header, required: false, schema: { type: string } }
      responses:
        '200':
          description: The Parquet file.
          headers:
            X-Ironbox-Data-From: { description: First day served., schema: { type: string, format: date } }
            X-Ironbox-Data-To: { description: Last day served., schema: { type: string, format: date } }
            X-Ironbox-Rows: { description: Rows in the file., schema: { type: integer } }
            ETag: { schema: { type: string } }
            Cache-Control: { schema: { type: string, example: "private, max-age=86400" } }
          content:
            application/vnd.apache.parquet:
              schema: { type: string, format: binary }
        '304':
          description: The file has not changed since the `ETag` sent.
        '400':
          description: "`INVALID_PARAMETER` (market or a day), `SPAN_TOO_LONG` (over 92 days) or `OUT_OF_WINDOW` (the days are before this caller's window)."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: SPAN_TOO_LONG
                  message: "one request returns at most 92 days, this asks for 181: split from and to"
        '401': { $ref: '#/components/responses/BoxDataUnauthorized' }
        '404':
          description: "`UNKNOWN_DATASET` or `UNKNOWN_MARKET`."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: UNKNOWN_MARKET
                  message: "\"BTC\" is not a Hyperliquid perp. Use the symbol from GET /v1/markets, such as BTC-PERP"
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: "`BOX_DATA_BUSY` or `BOX_DATA_UNAVAILABLE`: retry after `Retry-After` seconds, or use the data already on disk."
          headers:
            Retry-After: { schema: { type: integer, example: 5 } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/keys:
    post:
      operationId: requestFreeKey
      summary: Request a free API key
      description: |
        Emails a free API key to the given address. The key is never returned
        in the response; receiving it proves the address, so a person has to
        open the email and copy the key (it usually arrives within a minute).
        Requesting again replaces the previous free key. An address that
        already has a paid key gets a notice and keeps its key. The response
        is the same in every case. Addresses at throwaway inbox services are
        refused (400 `DISPOSABLE_EMAIL`).

        Limits: 3 requests per IP per hour, 1 per email per day, 20 per email
        domain per day.
      tags: [System]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 254
            example:
              email: dev@example.com
      responses:
        '202':
          description: The email was sent (or the address already has a paid key).
          content:
            application/json:
              schema:
                type: object
                required: [status, email, delivered_by, usually_arrives_in, message]
                properties:
                  status:
                    type: string
                    enum: [sent]
                  email:
                    type: string
                    description: The normalized address the key was sent to.
                  delivered_by:
                    type: string
                    enum: [email]
                    description: The key is only ever delivered by email, never in this response.
                  usually_arrives_in:
                    type: string
                    description: How long the email usually takes.
                  message:
                    type: string
                    description: What happens next, in plain words, for an agent to pass on to its user.
              example:
                status: sent
                email: dev@example.com
                delivered_by: email
                usually_arrives_in: 1 minute
                message: 'We emailed the key to dev@example.com. It is not in this response: a person has to open that inbox and copy the key from the email "Your Ironflow API key" (it usually arrives within 1 minute; check the spam folder too). Until then, calls without a key keep working at the keyless limits. Asking again replaces the key, once a day per address. By requesting a key you accept the Terms at https://ironflow.sh/terms.'
        '400':
          description: Missing or malformed email (`INVALID_EMAIL`), or an address at a throwaway inbox service (`DISPOSABLE_EMAIL`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: Too many requests from this IP, for this email or for this email domain.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: Email delivery is unavailable; try again later.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1:
    get:
      operationId: getIndex
      summary: API index
      description: |
        The main endpoints with one line each, and links to the docs, this
        spec, the MCP server, the free key page and the x402 discovery file.
        Also served at `/`. Works without a key (it counts toward the
        keyless budget).
      tags: [System]
      security:
        - {}
        - bearerAuth: []
      responses:
        '200':
          description: Index.
          content:
            application/json:
              schema:
                type: object
                required: [name, about, endpoints, docs]
                properties:
                  name: { type: string }
                  about: { type: string }
                  endpoints:
                    type: object
                    description: '"METHOD /path" to a one-line description.'
                    additionalProperties: { type: string }
                  docs: { type: string, format: uri }
                  openapi: { type: string, format: uri }
                  status: { type: string, format: uri }
                  mcp: { type: string, format: uri }
                  free_key: { type: string, format: uri }
                  try: { type: string, format: uri }
                  x402: { type: string, format: uri }
              example:
                name: Ironflow API
                about: "Hyperliquid market data from our own nodes: trades, liquidations, funding, open interest, candles, wallet analytics and SQL. No key needed to start."
                endpoints:
                  GET /v1/liquidations/map: where open positions would be liquidated, by price
                  GET /v1/trades: every trade (fill) for a market or wallet
                docs: https://docs.ironflow.sh
                openapi: https://docs.ironflow.sh/openapi.yaml
                status: https://api.ironflow.sh/v1/status
                mcp: https://mcp.ironflow.sh/mcp
                free_key: https://ironflow.sh/key?utm_source=api_root
                try: https://api.ironflow.sh/v1/liquidations/summary
                x402: https://api.ironflow.sh/openapi.json

  /v1/predictions/summary:
    get:
      operationId: getPredictionsSummary
      summary: HIP-4 outcome markets summary
      description: |
        Activity on Hyperliquid's HIP-4 outcome (prediction) markets over a
        trailing window: traded volume, fills, wallets and active markets, an
        hourly series with the BTC price beside it, and the active markets
        with their last trade and 24-hour volume. Refreshed every 60 seconds.
        No key and no rate tier: the limit is per IP.

        `notional_total` and `notional_24h` are not traded volume: they add up
        every fill row, so both sides of each trade plus settlements, splits
        and merges. Use `volume_usd` and `volume_24h`, which count each trade
        once. `last_price` is the last fill of any kind and can read 0.5 after
        a split or merge. Use `last_trade_price` for the market price.
      tags: [Market Data]
      security: []
      parameters:
        - name: window
          in: query
          required: false
          description: Trailing window. An unknown value falls back to `1d`.
          schema:
            type: string
            enum: [4h, 1d, 1w]
            default: 1d
      responses:
        '200':
          description: Summary of the window.
          content:
            application/json:
              schema:
                type: object
                required: [window_hours, volume_usd, notional_total, fills_total, wallets_total, markets_active, hourly, active_markets, computed_at]
                properties:
                  window_hours: { type: integer }
                  volume_usd: { type: number, description: "USD traded in the window: Hyperliquid's own taker buys and sells, each trade counted once." }
                  notional_total: { type: number, description: "Not traded volume. USD notional over every outcome fill row in the window: both sides of each trade plus settlements, splits and merges. Use volume_usd." }
                  fills_total: { type: integer, description: "Every fill row in the window, both sides of each trade plus settlements, splits and merges" }
                  wallets_total: { type: integer, description: "Wallets with any fill in the window, including wallets that only settled, split or merged" }
                  markets_active: { type: integer, description: Outcome sides with at least one fill in the window }
                  hourly:
                    type: array
                    items:
                      type: object
                      properties:
                        hour: { type: string, format: date-time }
                        notional: { type: number }
                        fills: { type: integer }
                        btc: { type: number, description: BTC-PERP volume-weighted price that hour }
                  active_markets:
                    type: array
                    items:
                      type: object
                      properties:
                        market_id: { type: integer }
                        display_symbol: { type: string }
                        hl_coin: { type: string, description: 'Hyperliquid coin, #N' }
                        last_price: { type: number, description: "Price of the last fill of any kind, so it can read 0.5 after a split or merge. Use last_trade_price for the market price." }
                        last_fill_ms: { type: integer, format: int64 }
                        last_trade_price: { type: number, description: "Price of this coin's last taker trade in 24 hours; 0 when it had none." }
                        last_trade_ms: { type: integer, format: int64, description: "Time of that trade, Unix milliseconds; 0 when it had none." }
                        fills_24h: { type: integer }
                        notional_24h: { type: number, description: "Not traded volume. USD notional over every fill row of this coin in 24 hours (see notional_total). Use volume_24h." }
                        volume_24h: { type: number, description: "USD traded on this coin in 24 hours, each trade counted once. One outcome's two coins trade one order book: sum both for the outcome's volume." }
                  computed_at: { type: integer, format: int64, description: Unix seconds }
              example:
                window_hours: 4
                volume_usd: 546434.1
                notional_total: 1092868.21
                fills_total: 30964
                wallets_total: 503
                markets_active: 479
                hourly:
                  - hour: '2026-11-06T17:00:00Z'
                    notional: 207216.14
                    fills: 6766
                    btc: 75646.09
                active_markets:
                  - market_id: 214730
                    display_symbol: English Premier League 2026/2027-Arsenal-Yes
                    hl_coin: '#14730'
                    last_price: 0.69582
                    last_fill_ms: 1794000132818
                    last_trade_price: 0.69582
                    last_trade_ms: 1794000132818
                    fills_24h: 2655
                    notional_24h: 179426.32
                    volume_24h: 89713.16
                computed_at: 1794000728

  /v1/predictions/history:
    get:
      operationId: getPredictionsHistory
      summary: HIP-4 outcome volume by month
      description: |
        Traded volume on Hyperliquid's HIP-4 outcome markets by calendar month
        since August 2026 (the first month with direction recorded on every
        fill) plus the last 7 days. Each trade counts once, as in Hyperliquid's
        outcome candles. Read from the hourly rollup, so figures end at the
        last complete hour (`as_of`). Cached for 10 minutes. No key.
      tags: [Market Data]
      security: []
      responses:
        '200':
          description: Monthly volume.
          content:
            application/json:
              schema:
                type: object
                required: [months, volume_7d_usd, as_of, computed_at]
                properties:
                  months:
                    type: array
                    items:
                      type: object
                      required: [month, volume_usd, trades, partial]
                      properties:
                        month: { type: string, description: YYYY-MM, example: '2026-10' }
                        volume_usd: { type: number }
                        trades: { type: integer }
                        partial: { type: boolean, description: The current month, not over yet }
                  volume_7d_usd: { type: number }
                  as_of: { type: string, format: date-time, description: Last complete hour the rollup covers }
                  computed_at: { type: integer, format: int64, description: Unix seconds }
              example:
                months:
                  - { month: '2026-08', volume_usd: 11970447, trades: 301237, partial: false }
                  - { month: '2026-10', volume_usd: 76556531, trades: 3663309, partial: false }
                volume_7d_usd: 48000000
                as_of: '2026-11-08T10:00:00Z'
                computed_at: 1794137500

  /v1/billing/plans:
    get:
      operationId: listPlans
      summary: List paid plans and how to pay
      description: |
        The plans an API key can be upgraded to, the founding offer while
        seats remain, and for each network (`networks`: Hyperliquid, and Base
        when enabled) the receiving address and the steps to pay from code. No
        key needed. `network`, `to_address` and `steps` at the top level are
        the Hyperliquid ones, kept for existing callers.
      tags: [Billing]
      security: []
      responses:
        '200':
          description: Plans and payment instructions.
          content:
            application/json:
              schema:
                type: object
                required: [plans, days, token, network, to_address, steps]
                properties:
                  plans:
                    type: array
                    items: { $ref: '#/components/schemas/BillingPlan' }
                  offers:
                    type: array
                    description: Offers beside the plans (the founding price) while seats remain.
                    items: { $ref: '#/components/schemas/BillingPlan' }
                  days: { type: integer, description: Days one payment keeps a plan }
                  renewal: { type: string }
                  token: { type: string, enum: [USDC] }
                  network: { type: string, enum: [Hyperliquid] }
                  to_address: { type: string, description: Ironflow's receiving address on Hyperliquid }
                  networks:
                    type: array
                    description: Each network a plan can be paid on, with its address and steps.
                    items: { $ref: '#/components/schemas/PaymentNetwork' }
                  steps:
                    type: array
                    items: { type: string }
                  x402: { type: string, description: How to buy a plan in one x402 call instead }
                  page: { type: string, format: uri }
              example:
                plans:
                  - { id: explorer, name: Explorer, price_usdc: 49, requests_per_minute: 100, history_days: 90, x402_buy_url: 'https://api.ironflow.sh/x402/v1/plans/explorer/buy' }
                  - { id: builder, name: Builder, price_usdc: 149, requests_per_minute: 1000, history_days: 365, x402_buy_url: 'https://api.ironflow.sh/x402/v1/plans/builder/buy' }
                offers:
                  - { id: founding, name: Founding member (Explorer), price_usdc: 19, requests_per_minute: 100, history_days: 90, seats_left: 50, x402_buy_url: 'https://api.ironflow.sh/x402/v1/plans/founding/buy' }
                days: 30
                renewal: 'none: a plan runs for days after the payment; paying again extends it'
                token: USDC
                network: Hyperliquid
                to_address: '0x0000000000000000000000000000000000000000'
                networks:
                  - { id: hyperliquid, name: Hyperliquid, token: USDC, to_address: '0x0000000000000000000000000000000000000000', steps: ['POST https://api.ironflow.sh/v1/billing/upgrade with {"email", "wallet", "plan", "network": "hyperliquid"}'] }
                  - { id: base, name: Base, token: USDC, to_address: '0x0000000000000000000000000000000000000001', chain_id: 8453, token_contract: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', confirmations_required: 15, steps: ['POST https://api.ironflow.sh/v1/billing/upgrade with {"email", "wallet", "plan", "network": "base"}'] }
                steps:
                  - 'POST https://api.ironflow.sh/v1/billing/upgrade with {"email", "wallet", "plan"}'
                page: https://ironflow.sh/upgrade

  /v1/billing/upgrade:
    post:
      operationId: requestUpgrade
      summary: Start a plan upgrade paid in USDC on Base or Hyperliquid
      description: |
        Opens an upgrade request for the API key of `email`. Send `amount_usdc`
        USDC from `wallet` to `to_address` on `network`, in one transfer, then
        poll `status_url`. On Hyperliquid that is a usdSend signed by the
        wallet itself (an agent wallet cannot send); on Base it is an ERC-20
        transfer of native USDC (`token_contract`) from that wallet, which
        counts after `confirmations_required` blocks, about a minute. The key
        gets the plan's limits within a minute of the transfer, for 30 days;
        paying again extends it. An address without a key gets one by email.
        Limit: 10 requests per IP per hour.
      tags: [Billing]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, wallet, plan]
              properties:
                email: { type: string, format: email, maxLength: 254 }
                wallet: { type: string, pattern: '^0x[0-9a-fA-F]{40}$', description: The address you pay from on the chosen network }
                plan: { type: string, enum: [explorer, builder, founding] }
                network: { type: string, enum: [base, hyperliquid], default: hyperliquid, description: The network the USDC is sent on }
            example:
              email: dev@example.com
              wallet: '0x1234567890abcdef1234567890abcdef12345678'
              plan: explorer
              network: base
      responses:
        '200':
          description: The request is open; pay it.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UpgradeRequest' }
        '400':
          description: 'INVALID_BODY, INVALID_EMAIL, INVALID_WALLET, INVALID_PLAN or INVALID_NETWORK.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: 'FOUNDING_FULL (the founding seats are taken) or WALLET_LINKED (the wallet pays for another email).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: Too many upgrade requests from this IP.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: Payments are not set up on this server.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    get:
      operationId: getUpgradeStatus
      summary: Upgrade request status
      description: |
        The status of one upgrade request: `pending`, `paid` or `expired`.
        While pending it lists the transfers from the wallet to the receiver
        seen so far; once paid, the payment, the tier and when the plan ends.
        The request id is the secret, so no key is needed.
      tags: [Billing]
      security: []
      parameters:
        - name: request
          in: query
          required: true
          description: '`request_id` from POST /v1/billing/upgrade (`up_` and 32 hex characters).'
          schema: { type: string, pattern: '^up_[0-9a-f]{32}$' }
      responses:
        '200':
          description: The request.
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/UpgradeRequest' }
                  - type: object
                    properties:
                      created_at: { type: string, format: date-time }
                      paid:
                        type: object
                        properties:
                          amount_usdc: { type: number }
                          tx_hash: { type: string }
                          at: { type: string, format: date-time }
                      tier: { type: string }
                      plan_active_until: { type: string, format: date-time }
                      via: { type: string, enum: [x402], description: 'Present when the request was a one-call x402 purchase' }
                      transfers_seen:
                        type: array
                        items:
                          type: object
                          properties:
                            amount_usdc: { type: number }
                            tx_hash: { type: string }
                            at: { type: string, format: date-time }
                            confirmations: { type: integer, description: On Base only, blocks since the transfer }
        '400':
          description: INVALID_REQUEST_ID.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: No upgrade request with this id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /x402/v1/plans/{plan}/buy:
    post:
      operationId: buyPlanX402
      summary: Buy a 30-day plan with x402
      description: |
        Buys a plan in one call with x402: USDC on Base, no account. The
        first call answers 402 with the payment terms in the
        `PAYMENT-REQUIRED` header; pay them with any x402 client and send the
        same request again with `PAYMENT-SIGNATURE`. The plan applies to the
        keys of `email` (or a new key is emailed there) once the payment
        settles. A 4xx answer cancels the payment. Prices: GET
        /v1/billing/plans.
      tags: [Billing]
      security: []
      parameters:
        - name: plan
          in: path
          required: true
          schema: { type: string, enum: [explorer, builder, founding] }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email, maxLength: 254 }
            example:
              email: dev@example.com
      responses:
        '200':
          description: Paid; the plan starts within a minute.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [paid] }
                  plan: { type: string }
                  plan_name: { type: string }
                  tier: { type: string }
                  days: { type: integer }
                  email: { type: string }
                  request_id: { type: string }
                  status_url: { type: string, format: uri }
                  message: { type: string }
        '400':
          description: INVALID_BODY or INVALID_EMAIL.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '402':
          description: Payment required. The terms are in the `PAYMENT-REQUIRED` header.
          headers:
            PAYMENT-REQUIRED:
              description: Base64 x402 payment requirements (price, network, asset, pay-to address).
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: No such plan.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: FOUNDING_FULL or WALLET_LINKED.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/query:
    get:
      operationId: runQueryGet
      summary: Run a read-only SQL query from a URL
      description: |
        The same query as `POST /v1/query`, with the SQL in the URL, for clients that can
        only fetch URLs, such as a chat assistant's web tool. URL-encode the SQL. Same
        limits as POST; responses are never cached. Works without a key.
      tags: [Query]
      security:
        - {}
        - bearerAuth: []
      parameters:
        - name: sql
          in: query
          required: true
          description: One read-only ClickHouse statement, URL-encoded.
          schema: { type: string, maxLength: 20000 }
          example: "SELECT market, count() AS fills FROM hl.fills WHERE ts > now() - INTERVAL 1 HOUR GROUP BY market ORDER BY fills DESC LIMIT 5"
        - name: max_rows
          in: query
          description: Most rows to return. Defaults to, and is capped at, the tier limit.
          schema: { type: integer, minimum: 1 }
      responses:
        '200':
          description: Query result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QueryResult' }
    post:
      operationId: runQuery
      summary: Run a read-only SQL query
      description: |
        Runs one ClickHouse SELECT over the curated `hl.*` views (fills, wallet_daily,
        wallet_behavior, funding, mark_prices, oracle_prices, open_interest, liquidations,
        builder_fills, transfers, vault_operations). `GET /v1/query/schema` lists every
        table, column and example query.

        The tier sets every limit, and ClickHouse enforces them:

        | Tier | History | Per query | Rows | Query seconds per hour |
        |---|---|---|---|---|
        | No key (per IP) | 1 day | 5 s | 1,000 | 60 |
        | Free key | 30 days | 10 s | 1,000 | 300 |
        | Explorer | 90 days | 20 s | 5,000 | 1,800 |
        | Builder | 365 days | 30 s | 10,000 | 7,200 |
        | Enterprise | everything stored | 30 s | 10,000 | 36,000 |

        Rows older than the tier's history window are filtered out of every view (not an
        error). Only SELECT, WITH, DESCRIBE, SHOW and EXPLAIN are accepted, one statement
        per request, without a FORMAT clause. Works without a key. For a Parquet or CSV
        file, `POST /v1/query/export` (paid plans).
      tags: [Query]
      security:
        - {}
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sql]
              properties:
                sql:
                  type: string
                  maxLength: 20000
                  description: One read-only ClickHouse statement.
                max_rows:
                  type: integer
                  minimum: 1
                  description: Most rows to return. Defaults to, and is capped at, the tier limit.
            example:
              sql: "SELECT market, round(sum(notional) / 2) AS volume_usd FROM hl.fills WHERE ts > now() - INTERVAL 1 HOUR GROUP BY market ORDER BY volume_usd DESC LIMIT 5"
              max_rows: 5
      responses:
        '200':
          description: Query result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QueryResult' }
              example:
                columns:
                  - { name: market, type: LowCardinality(String) }
                  - { name: volume_usd, type: Float64 }
                rows:
                  - [BTC-PERP, 181234567]
                  - [ETH-PERP, 92345678]
                row_count: 2
                truncated: false
                stats: { elapsed_ms: 212.4, rows_read: 612345, bytes_read: 24493800 }
                limits: { history_days: 1, max_execution_seconds: 5, max_rows: 1000, budget_seconds_per_hour: 60, budget_rows_per_hour: 2000000000 }
                budget_remaining_seconds: 59.7
        '400':
          description: |
            The statement was rejected or failed. `code` is one of `INVALID_REQUEST`,
            `INVALID_QUERY` (not a single read statement), `QUERY_ERROR` (ClickHouse error,
            message included, with the column or table to use when a name is wrong and the
            endpoint that answers the query when there is one), `QUERY_NOT_ALLOWED` (table
            outside hl.* or a locked setting), `QUERY_TOO_EXPENSIVE` (read or memory limit).
            A query whose alias reuses a column name inside an aggregate (`round(sum(fee)) AS fee`
            next to `sum(fee)`) is run with `prefer_column_name_to_alias = 1` and answered 200
            with a `notice`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: Rate limit, too many concurrent queries, or the hourly query time or rows budget (`QUERY_BUDGET_EXCEEDED`) is used up.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '504':
          description: The query ran past the tier's per-query time limit (`QUERY_TIMEOUT`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/query/export:
    post:
      operationId: exportQuery
      summary: Export a SQL query as a Parquet or CSV file
      description: |
        Runs one read-only ClickHouse SELECT over the `hl.*` views and returns the result as a
        file, Parquet by default. It is for pulling a window of data you would otherwise page
        through `/v1/query`, including what the free datasets do not hold: every fill with fees
        and PnL (`hl.fills`), per-wallet tables, sub-hourly marks and any filter.

        | Plan | History | Time per export | Rows per file |
        |---|---|---|---|
        | Explorer | 90 days | 60 s | 1,000,000 |
        | Builder | 365 days | 85 s | 5,000,000 |
        | Enterprise | everything stored | 85 s | 10,000,000 |

        One export runs per key at a time, and a few across the service (`429 EXPORT_BUSY`).
        Time counts toward the plan's hourly SQL budget. Over the row cap the export fails with
        `EXPORT_TOO_LARGE` rather than returning a cut file: split it by `ts`. Nothing is sent
        until the file is complete, so a response that starts is whole. Finish the download
        within 15 minutes. In Parquet, `DateTime` columns are written as Unix seconds; cast with
        `toDateTime64(col, 3)` for a timestamp. `DateTime64` columns (`ts` on most tables) keep
        their timestamp type. No pay-per-call: needs a key on a paid plan.
      tags: [Query]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sql]
              properties:
                sql:
                  type: string
                  maxLength: 20000
                  description: One read-only ClickHouse statement, without a FORMAT clause.
                format:
                  type: string
                  enum: [parquet, csv]
                  default: parquet
                  description: File format.
            example:
              sql: "SELECT ts, tid, side, price, size, notional FROM hl.trades WHERE market = 'BTC-PERP' AND ts >= toDate('2026-10-15') AND ts < toDate('2026-10-16')"
              format: parquet
      responses:
        '200':
          description: The file. `Content-Disposition` names it; `X-Ironflow-Export-Rows` and `X-Ironflow-Export-Max-Rows` give its size against the cap.
          headers:
            X-Ironflow-Export-Rows:
              description: Rows in the file.
              schema: { type: integer }
            X-Ironflow-Export-Max-Rows:
              description: The plan's row cap per file.
              schema: { type: integer }
          content:
            application/vnd.apache.parquet:
              schema: { type: string, format: binary }
            text/csv:
              schema: { type: string }
        '400':
          description: "`EXPORT_TOO_LARGE` over the row cap, or the statement was rejected: `INVALID_REQUEST`, `INVALID_PARAMETER`, `INVALID_QUERY`, `QUERY_ERROR`, `QUERY_NOT_ALLOWED`, `QUERY_TOO_EXPENSIVE`."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '402':
          description: "`TIER_LIMIT`: free keys and keyless callers get the standard `upgrade` object naming Explorer."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error:
                  code: TIER_LIMIT
                  message: "SQL file export (Parquet or CSV) needs a paid plan: Explorer or Builder, see the upgrade object. The free datasets cover whole-market history: https://ironflow.sh/datasets"
                  upgrade: { plan: explorer, price_usdc: 49, days: 30, unlocks: "SQL export to Parquet or CSV (1,000,000 rows a file), 100 requests a minute, 90 days of history", limit: tier, lifts: "unlocked by Explorer", url: "https://ironflow.sh/upgrade?utm_source=api&plan=explorer&limit=tier", how: "POST https://api.ironflow.sh/v1/billing/upgrade with your email, wallet, plan and network, then send the USDC" }
        '429':
          description: "`EXPORT_BUSY`, `RATE_LIMIT_EXCEEDED`, or `QUERY_BUDGET_EXCEEDED`."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          description: "`EXPORT_UNAVAILABLE`: exports are not switched on."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '504':
          description: "`EXPORT_TIMEOUT`: the export ran past the plan's time limit."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/query/schema:
    get:
      operationId: getQuerySchema
      summary: Describe the queryable tables
      description: |
        Returns the `hl.*` tables with column names, types and descriptions, how far back
        each table goes, query tips, worked example queries, the caller's own limits and
        every tier's limits. Read this before writing SQL for `POST /v1/query`.
      tags: [Query]
      security:
        - {}
        - bearerAuth: []
      responses:
        '200':
          description: The catalog.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QueryCatalog' }
              example:
                dialect: "ClickHouse SQL (read-only). Tables live in the hl database: write hl.fills, hl.funding, and so on."
                tables:
                  - name: hl.funding
                    description: Hourly funding rate per perp market, native and HIP-3.
                    time_column: ts
                    data_from: "2025-09-27"
                    columns:
                      - { name: ts, type: DateTime64 UTC, description: Event time }
                      - { name: rate, type: Float64, description: "Funding rate for that hour as a fraction." }
                tips: ["Always filter hl.fills on ts, and on market when you can."]
                examples:
                  - question: Which perps pay the most extreme funding right now, annualized?
                    sql: "SELECT market, argMax(rate, ts) AS last_rate FROM hl.funding WHERE ts > now() - INTERVAL 2 HOUR GROUP BY market ORDER BY abs(last_rate) DESC LIMIT 10"
                limits: { history_days: 1, max_execution_seconds: 5, max_rows: 1000, budget_seconds_per_hour: 60, budget_rows_per_hour: 2000000000 }
                tiers:
                  - { tier: anon, history_days: 1, max_execution_seconds: 5, max_rows: 1000, budget_seconds_per_hour: 60, budget_rows_per_hour: 2000000000 }

  /v1/markets:
    get:
      operationId: listMarkets
      summary: List active markets
      description: |
        Returns every currently-active market in the registry. Reads directly
        from the Postgres markets registry — no ClickHouse round-trip and no
        per-tier query window applies.

        Use this endpoint to discover the symbols you can pass to other
        endpoints, or to scope cross-issuer queries by `base_market` + `issuer`.

        Each row carries:
        - `display_symbol` — what callers should show ("BTC-PERP",
          "flx:GAS-PERP", "HFUN/USDC").
        - `base_market` — the symbol with any HIP-3 issuer prefix stripped
          ("GAS-PERP" for both `BTC-PERP` and `flx:GAS-PERP`); pair with
          `issuer` to query across builders that share a base symbol.
        - `issuer` — empty for native HL markets, the HIP-3 builder code
          ("flx") otherwise.
        - `hl_coin` — the raw venue wire-form ("BTC", "flx:GAS", "@132").
          Useful for debugging or when joining with raw venue feeds.

        Filters:
        - `source` — venue (defaults to `hyperliquid`).
        - `market_class` — `perp`, `spot`, `prediction`, or `option`. Omit
          to return every active market across all classes.
        - `issuer` — restrict to a single HIP-3 builder. Pass an empty value
          (`?issuer=`) to return only native non-builder markets.
        - `q`: case-insensitive search over symbol, base asset, HL coin and
          issuer (`q=nvda`).
        - `include_inactive=true`: also return closed markets: delisted perps
          and spot, and HIP-4 outcomes that have settled. Closed rows carry
          `active_to_ms`. Their history stays queryable on the other endpoints.

        HIP-4 outcomes are listed while Hyperliquid lists them and closed
        when they settle, so the default response holds only tradable markets.

        Paging: without `limit` every match is returned. With `limit` (1 to
        5000) the response adds `pagination`; pass `next_cursor` back as
        `cursor` with the same filters for the next page.
      tags: [System]
      parameters:
        - $ref: '#/components/parameters/source'
        - name: market_class
          in: query
          description: Restrict to one class. Omit to return every active market.
          schema:
            type: string
            enum: [perp, spot, prediction, option]
          example: perp
        - $ref: '#/components/parameters/issuer'
        - name: q
          in: query
          description: Case-insensitive search over symbol, base asset, HL coin and issuer.
          schema:
            type: string
          example: nvda
        - name: include_inactive
          in: query
          description: Also return closed markets (delisted, or settled HIP-4 outcomes), with `active_to_ms`.
          schema:
            type: boolean
            default: false
        - name: limit
          in: query
          description: Page size, 1 to 5000. Omit to return every match.
          schema:
            type: integer
            minimum: 1
            maximum: 5000
        - name: cursor
          in: query
          description: The `pagination.next_cursor` of the previous page.
          schema:
            type: string
      responses:
        '200':
          description: Active markets matching the filters.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MarketsResponse'
              example:
                count: 3
                data:
                  - market_id: 1
                    source: hyperliquid
                    market_class: perp
                    issuer: ""
                    base_asset: BTC
                    quote_asset: USDC
                    base_market: BTC-PERP
                    display_symbol: BTC-PERP
                    hl_coin: BTC
                    active_from_ms: 1722038400000
                  - market_id: 132
                    source: hyperliquid
                    market_class: perp
                    issuer: flx
                    base_asset: GAS
                    quote_asset: USDC
                    base_market: GAS-PERP
                    display_symbol: flx:GAS-PERP
                    hl_coin: "@132"
                    active_from_ms: 1730000000000
                  - market_id: 5000
                    source: hyperliquid
                    market_class: spot
                    issuer: ""
                    base_asset: HFUN
                    quote_asset: USDC
                    base_market: HFUN/USDC
                    display_symbol: HFUN/USDC
                    hl_coin: HFUN
                    active_from_ms: 1722038400000
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '504':
          description: Markets registry lookup timed out.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /v1/status:
    get:
      operationId: getStatus
      summary: System status
      description: |
        Returns overall health, per-venue data freshness, and platform
        aggregates. Does not require authentication.
      tags: [System]
      security: []
      responses:
        '200':
          description: System status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StatusResponse'
              example:
                status: operational
                timestamp: 1745145533000
                uptime: 3h12m40s
                platform:
                  total_events: 1843000000
                  total_markets: 568
                  event_types_active: 9
                  oldest_data_days: 266
                components:
                  data_pipeline: { status: operational, last_seen_age_seconds: 4.2 }
                  rest_api:      { status: operational, last_seen_age_seconds: 4.2 }
                  streaming:     { status: operational, last_seen_age_seconds: 4.2 }
                venues:
                  hyperliquid:
                    status: operational
                    markets: 568
                    streams:
                      fills:
                        latest_timestamp: "2026-04-20 09:58:53.033"
                        age_seconds: 0.41
                        total_rows: 1843000000
                      order_statuses:
                        latest_timestamp: "2026-04-20 09:58:53.044"
                        age_seconds: 0.39
                        total_rows: 3500000000
                      book_snapshots:
                        latest_timestamp: "2026-04-20 09:58:52.900"
                        age_seconds: 1.14
                        total_rows: 21000000
                    events:
                      liquidations:
                        latest_timestamp: "2026-04-20 09:54:12.019"
                        age_seconds: 281.0
                        total_rows: 1204000
                      funding_rates:
                        latest_timestamp: "2026-04-20 09:00:00.000"
                        age_seconds: 3533.0
                        total_rows: 150000

  /v1/status/metrics:
    get:
      operationId: getStatusMetrics
      summary: Real-time API + pipeline performance metrics
      description: |
        Returns rolling-window performance metrics: API error rate and request
        latency percentiles (5-minute window from Prometheus), ingest rate
        and lag, and the latest synthetic probe samples (per-endpoint query
        latency). The WebSocket fields date from a retired WebSocket API and
        read 0.

        The shape is stable but values are best-effort — if Prometheus is
        unreachable, missing fields are returned as 0 rather than 5xx.
      tags: [System]
      security: []
      responses:
        '200':
          description: Performance metrics snapshot.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StatusMetricsResponse'
              example:
                timestamp: 1745145533000
                api:
                  latency_p50_ms: 12.3
                  latency_p99_ms: 87.1
                  error_rate: 0.0012
                  refusal_rate: 0.31
                  websocket_clients: 47
                pipeline:
                  streaming_latency_ms: 220
                  events_per_sec: 13500
                synthetic:
                  ws_latency_ms: 41
                  ws_latency_p50_ms: 38
                  query_latencies:
                    fills: 28
                    book: 31
                    candles: 15

  /v1/status/history:
    get:
      operationId: getStatusHistory
      summary: Uptime / freshness timeline
      description: |
        Returns a fixed-width uptime timeline of clock-hour buckets sourced from
        Prometheus. A `24h` request always returns exactly 24 buckets aligned
        to the top of each hour; `7d` returns 168. Each bucket reports the
        WORST status of every sample within that hour against the unified
        threshold band: `operational` when max data freshness across high-
        frequency tables (`fills`, `order_statuses`, `book_snapshots`,
        `mark_prices`, `open_interest`) stayed ≤ 60s for the entire hour,
        `degraded` past 60s, `down` past 5 minutes (or when Prometheus
        reported `NaN`).

        Buckets are aligned to clock hours rather than a sliding window so the
        timeline only changes shape at the top of each hour — a degraded tile
        cannot silently disappear between polls.

        `uptime_percent` is the share of buckets in `operational` state over
        the requested period.
      tags: [System]
      security: []
      parameters:
        - name: period
          in: query
          description: Lookback window. Defaults to `24h`.
          schema:
            type: string
            enum: [24h, 7d]
            default: 24h
      responses:
        '200':
          description: Uptime timeline for the requested period.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StatusHistoryResponse'
              example:
                period: 24h
                uptime_percent: 99.86
                points:
                  - ts: 1745059200000
                    status: operational
                    fresh_sec: 1.4
                  - ts: 1745059800000
                    status: operational
                    fresh_sec: 1.8

  /healthz:
    get:
      operationId: healthCheck
      summary: Health check
      description: Load balancer health check. Returns 200 if the server is up.
      tags: [System]
      security: []
      responses:
        '200':
          description: Server is healthy.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
              example:
                status: ok

# ─── Components ──────────────────────────────────────────────────────────────

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Ironflow API key, format `if_xxxx`. Free keys at https://ironflow.sh/key. Optional on read endpoints.

    IronboxToken:
      type: apiKey
      in: header
      name: X-Ironbox-Token
      description: A paper box secret (`ibxs_...`, from the box's `base_url`). Reads box data only.

    WalletSession:
      type: apiKey
      in: cookie
      name: ironflow_wallet_session
      description: |
        Cookie issued by /v1/auth/wallet/verify. Distinct from the
        Authorization-bearer API key path used by the existing data API.

  # ─── Reusable Parameters ──────────────────────────────────────────────────

  parameters:
    BoxSecret:
      name: secret
      in: path
      required: true
      description: "The box secret, the last part of the box's `base_url`. Anyone with it can trade the box, and only the box."
      schema: { type: string, pattern: '^ibxs_[0-9a-f]{64}$' }
      example: ibxs_1f2e3d4c5b6a7890123456789abcdef0123456789abcdef0123456789abcdef0
    ClosePosition:
      name: close_position
      in: query
      required: false
      description: "`false` leaves the strategy's position open on the box."
      schema: { type: boolean, default: true }
    source:
      name: source
      in: query
      description: Venue identifier. Defaults to `hyperliquid`.
      schema:
        type: string
        enum: [hyperliquid]
        default: hyperliquid
      example: hyperliquid

    market:
      name: market
      in: query
      description: Market symbol (e.g. `BTC-PERP`, `ETH-PERP`). Omit to query all markets.
      schema:
        type: string
        example: BTC-PERP

    base_market:
      name: base_market
      in: query
      description: |
        Base market symbol, stripped of any issuer namespace (e.g. `GAS-PERP`).
        Matches both native HL markets (`market` == `base_market`) and every HIP-3
        builder-deployed market with that base (e.g. `flx:GAS-PERP`). Use together
        with `issuer` to scope to a single builder, or alone to query across all
        issuers of the same base symbol.
      schema:
        type: string
        example: GAS-PERP

    issuer:
      name: issuer
      in: query
      description: |
        HIP-3 builder/issuer namespace (e.g. `flx`). For native HL perps the issuer
        is the empty string; do not combine `issuer=""` with this filter — omit the
        parameter instead. Commonly paired with `base_market` to disambiguate a
        builder-deployed market from its native counterpart.
      schema:
        type: string
        example: flx

    address:
      name: address
      in: query
      description: Wallet address filter (hex string, e.g. `0xabc123`).
      schema:
        type: string
        example: "0xabc123def456"

    from:
      name: from
      in: query
      description: Start time as Unix milliseconds. Defaults to 24 hours ago. Clamped to tier history window. On /v1/trades and /v1/fills, the time range (to - from) is limited per tier (no key 1h, free key 6h, Explorer 24h, Builder 7d, Enterprise unlimited; without market filter 4h for all tiers). Returns QUERY_WINDOW_EXCEEDED (400) if exceeded — paginate with narrower from/to windows.
      schema:
        type: integer
        format: int64
        example: 1718313600000

    to:
      name: to
      in: query
      description: End time as Unix milliseconds. Defaults to now.
      schema:
        type: integer
        format: int64
        example: 1718400000000

    limit:
      name: limit
      in: query
      description: Maximum number of results per page. Default 100, max 1000.
      schema:
        type: integer
        default: 100
        maximum: 1000
        example: 100

    cursor:
      name: cursor
      in: query
      description: Opaque pagination cursor from `pagination.next_cursor` in a previous response.
      schema:
        type: string
        example: "MTcxODQwMDAwMDAwMC00Mg=="

  # ─── Reusable Schemas ─────────────────────────────────────────────────────

  schemas:
    LiquidationAlert:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        type: { type: string, enum: [webhook, discord, slack, telegram] }
        destination: { type: string, description: "Host of the URL, without its secret part, or the Telegram chat.", example: "discord.com/…" }
        markets: { type: array, items: { type: string }, description: "Empty: every market.", example: ["BTC-PERP", "ETH-PERP"] }
        side: { type: string, enum: [any, long, short] }
        min_usd: { type: number, example: 100000 }
        address: { type: string }
        status: { type: string, enum: [active, disabled], description: "disabled after 20 failed deliveries in a row; a delivered test re-enables it." }
        failures: { type: integer, description: Failed deliveries in a row. }
        last_error: { type: string, example: "destination answered HTTP 404: Unknown Webhook" }
        last_sent_at: { type: string, format: date-time, nullable: true }
        sent_count: { type: integer, description: Liquidations delivered so far. }
        created_at: { type: string, format: date-time }
        signing_secret: { type: string, description: "Webhooks only, in the create response only.", example: "whsec_3f9a..." }
        setup: { type: string, description: "Create response only: what to do before the first message arrives (Telegram: which bot to add to the chat)." }
    QueryLimits:
      type: object
      required: [history_days, max_execution_seconds, max_rows, budget_seconds_per_hour, budget_rows_per_hour]
      properties:
        history_days: { type: integer, description: How far back every hl.* view reaches. }
        max_execution_seconds: { type: integer, description: Time limit for one query. }
        max_rows: { type: integer, description: Most rows one response returns. }
        budget_seconds_per_hour: { type: integer, description: Query time one caller may use per clock hour. }
        budget_rows_per_hour: { type: integer, format: int64, description: "Rows one caller may read per clock hour; 0 means no budget." }

    QueryResult:
      type: object
      required: [columns, rows, row_count, truncated, stats, limits, budget_remaining_seconds]
      properties:
        columns:
          type: array
          items:
            type: object
            required: [name, type]
            properties:
              name: { type: string }
              type: { type: string, description: ClickHouse type }
        rows:
          type: array
          description: Positional rows in `columns` order. Numbers stay JSON numbers; times are UTC strings.
          items:
            type: array
            items: {}
        row_count: { type: integer }
        truncated: { type: boolean, description: More rows existed than max_rows. }
        stats:
          type: object
          required: [elapsed_ms, rows_read, bytes_read]
          properties:
            elapsed_ms: { type: number }
            rows_read: { type: integer }
            bytes_read: { type: integer }
        limits: { $ref: '#/components/schemas/QueryLimits' }
        budget_remaining_seconds: { type: number }

    QueryCatalog:
      type: object
      required: [dialect, tables, tips, examples, limits, tiers]
      properties:
        dialect: { type: string }
        tables:
          type: array
          items:
            type: object
            required: [name, description, time_column, data_from, columns]
            properties:
              name: { type: string }
              description: { type: string }
              time_column: { type: string }
              data_from: { type: string, description: Earliest data kept, or a rolling window. }
              columns:
                type: array
                items:
                  type: object
                  required: [name, type, description]
                  properties:
                    name: { type: string }
                    type: { type: string }
                    description: { type: string }
        tips:
          type: array
          items: { type: string }
        examples:
          type: array
          items:
            type: object
            required: [question, sql]
            properties:
              question: { type: string }
              sql: { type: string }
        limits: { $ref: '#/components/schemas/QueryLimits' }
        tiers:
          type: array
          items:
            allOf:
              - { $ref: '#/components/schemas/QueryLimits' }
              - type: object
                required: [tier]
                properties:
                  tier: { type: string }

    MarketRisk:
      type: object
      required: [market_id, display_symbol, market_class, deployer, oracle_source, divergence_window_days, suggested_max_leverage, formula]
      properties:
        market_id:
          type: integer
        display_symbol:
          type: string
        market_class:
          type: string
          enum: [perp, spot, prediction, option]
        deployer:
          type: string
          description: HIP-3 issuer code; empty for native markets.
        oracle_source:
          type: string
        max_leverage:
          type: integer
          nullable: true
        max_divergence_bps_30d:
          type: number
          nullable: true
          description: Max |mark-oracle|/oracle in bps over the window; null until paired samples exist.
        divergence_window_days:
          type: integer
        suggested_max_leverage:
          type: integer
        formula:
          type: string

    MarketConstraints:
      type: object
      required: [market_id, display_symbol, market_class, deployer, oracle_source, price_sig_figs, min_notional_usd, at_oi_cap, lifecycle_events, as_of_ms]
      properties:
        market_id:
          type: integer
        display_symbol:
          type: string
        market_class:
          type: string
          enum: [perp, spot, prediction, option]
        deployer:
          type: string
          description: HIP-3 issuer code; empty for native markets.
        oracle_source:
          type: string
        max_leverage:
          type: integer
        margin_tiers:
          type: array
          items:
            type: object
            required: [lower_bound_usd, max_leverage]
            properties:
              lower_bound_usd:
                type: string
              max_leverage:
                type: integer
        sz_decimals:
          type: integer
          nullable: true
          description: Lot-size rule — sizes carry at most this many decimals.
        price_sig_figs:
          type: integer
        max_price_decimals:
          type: integer
          nullable: true
        min_notional_usd:
          type: number
        oi_cap_usd:
          type: number
          nullable: true
          description: Deployer-set open-interest cap (HIP-3 only; null for native markets).
        open_interest_usd:
          type: number
          nullable: true
        oi_utilization:
          type: number
          nullable: true
        at_oi_cap:
          type: boolean
          description: The market currently appears in Hyperliquid's at-cap list.
        lifecycle_events:
          type: array
          items:
            $ref: "#/components/schemas/MarketEvent"
        as_of_ms:
          type: integer
          format: int64

    WalletBorrow:
      type: object
      required: [address, positions, ledger_entries, ledger_types_seen, window_days, as_of_ms]
      properties:
        address:
          type: string
        health:
          type: string
        health_factor:
          type: number
          nullable: true
        positions:
          type: array
          description: Per-token borrow/lend states, raw from Hyperliquid.
          items:
            type: object
            required: [token_index, state]
            properties:
              token_index:
                type: integer
              state:
                type: object
        ledger_entries:
          type: array
          items:
            type: object
            required: [time_ms, type, delta]
            properties:
              time_ms:
                type: integer
                format: int64
              type:
                type: string
              delta:
                type: object
        ledger_types_seen:
          type: array
          items:
            type: string
        window_days:
          type: integer
        as_of_ms:
          type: integer
          format: int64

    WalletHealth:
      type: object
      required: [address, formula, current, as_of_ms]
      properties:
        address:
          type: string
        formula:
          type: string
          description: The disclosed health-factor and liquidation-price definition.
        current:
          $ref: "#/components/schemas/HealthResult"
        shocked:
          $ref: "#/components/schemas/HealthResult"
        shocks:
          type: object
          additionalProperties:
            type: number
        as_of_ms:
          type: integer
          format: int64

    HealthResult:
      type: object
      required: [account_value_usd, maintenance_margin_usd, margin_available_usd, health_factor, breached, legs]
      properties:
        account_value_usd:
          type: number
        maintenance_margin_usd:
          type: number
        margin_available_usd:
          type: number
        health_factor:
          type: number
        breached:
          type: boolean
        first_to_break:
          type: string
        legs:
          type: array
          items:
            type: object
            required: [coin, shock_pct, mark_px, shocked_mark_px, has_liquidation_px, distance_to_liq_pct]
            properties:
              coin:
                type: string
              shock_pct:
                type: number
              mark_px:
                type: number
              shocked_mark_px:
                type: number
              liquidation_px:
                type: number
              has_liquidation_px:
                type: boolean
              distance_to_liq_pct:
                type: number

    MarketEvent:
      type: object
      required: [id, venue, market_id, display_symbol, kind, details, created_at]
      properties:
        id:
          type: integer
          format: int64
        venue:
          type: string
        market_id:
          type: integer
        display_symbol:
          type: string
        kind:
          type: string
          enum: [delisted, rebased, oi_cap_changed, gated]
        details:
          type: object
          description: Kind-specific payload (e.g. old/new szDecimals for rebased).
        created_at:
          type: string
          format: date-time

    ApproveAgentAction:
      type: object
      description: HL exchange action that authorizes an agent sub-key.
      required: [type, hyperliquidChain, signatureChainId, agentAddress, agentName, nonce]
      properties:
        type: { type: string, enum: [approveAgent] }
        hyperliquidChain: { type: string, enum: [Mainnet, Testnet] }
        signatureChainId: { type: string, description: "0xa4b1 on Arbitrum mainnet" }
        agentAddress: { type: string }
        agentName: { type: string, maxLength: 32 }
        nonce: { type: integer, format: int64 }

    ApproveBuilderFeeAction:
      type: object
      description: |
        HL exchange action (type: approveBuilderFee) that authorizes a builder
        address to collect a per-order fee on the user's fills. Must be signed
        by the user's main wallet — the agent key cannot issue this approval.
        One-time per builder per user; revocable at any time from the HL UI.
      required: [type, hyperliquidChain, maxFeeRate, builder, nonce]
      properties:
        type:
          type: string
          enum: [approveBuilderFee]
        hyperliquidChain:
          type: string
          enum: [Mainnet, Testnet]
        maxFeeRate:
          type: string
          description: |
            Maximum per-order fee rate as a percentage string (e.g. "0.001%").
            The protocol rejects orders whose builder fee exceeds this cap.
          example: "0.001%"
        builder:
          type: string
          description: 0x-prefixed Ethereum address of the fee-collecting builder.
          example: "0x8c967E73E7B15087c42A10D344cFf4c96D877f1D"
        nonce:
          type: integer
          format: int64
          description: Unix-millisecond timestamp for replay protection.

    CreateStrategyMessage:
      type: object
      description: |
        The EIP-712 typed-data payload the wallet signs to authenticate a
        strategy-creation request. Uses the **IronflowStrategyCreate** domain
        (name: "IronflowStrategyCreate", version: "1", chainId: 1) — NOT HL's
        HyperliquidSignTransaction domain. This is an Ironflow authentication
        signature, not a Hyperliquid chain action.

        Field names match the Go struct exactly (PascalCase) so the server can
        decode and recompute the digest without transformation.
      required: [Wallet, Kind, Target, Size, StopLoss, Nonce, IssuedAt]
      properties:
        Wallet:
          type: string
          description: |
            0x-prefixed Ethereum address of the signing wallet. Must match the
            authenticated session wallet (case-insensitive).
          example: "0xaaaa1111111111111111111111111111111111aa"
        Kind:
          type: string
          description: |
            Strategy kind. Params travel JSON-stringified in Size. Kinds
            "copy" and "copy_trade" are rejected with 400 since copy trading was
            discontinued on 2026-09-15.
          enum: [range, range_rider, dca, twap, iceberg, chase, basket, protect, liq_hunter, trend, whale_trigger, pair]
          example: "range"
        Target:
          type: string
          description: Unused by current kinds (empty string); kept so the signed EIP-712 message shape stays stable.
          example: ""
        Size:
          type: string
          description: The kind's params as a JSON-stringified object (RangeParams for kind=range, DCAParams for kind=dca, PairParams for kind=pair, and so on).
          contentMediaType: application/json
          contentSchema:
            anyOf:
              - $ref: '#/components/schemas/PairParams'
              - type: object
                description: Params of the other kinds (RangeParams, DCAParams, …).
          example: '{"kind":"range","mode":"grid","market":"BTC-PERP","side":"long","lower_px":"60000","upper_px":"70000","size_usd":"100","leverage":3}'
        StopLoss:
          type: string
          description: |
            Drawdown stop threshold as a signed percentage string (e.g. "-20"
            means -20%). Empty string means no stop configured.
          example: "-20"
        Nonce:
          type: integer
          format: int64
          description: Unix-millisecond timestamp for replay protection.
          example: 1749254400000
        IssuedAt:
          type: string
          format: date-time
          description: ISO 8601 / RFC 3339 timestamp when the message was created.
          example: "2026-06-06T12:00:00Z"

    PairLeg:
      type: object
      required: [coin, side, weight]
      properties:
        coin:
          type: string
          description: HL coin, Pear's instrument base ("BTC", "xyz:MSTR").
          example: "BTC"
        side:
          type: string
          enum: [long, short]
        weight:
          type: number
          exclusiveMinimum: 0
          maximum: 1
          description: The leg's share of the whole basket; all weights sum to 1.
          example: 0.5

    PairParams:
      type: object
      description: |
        Params of kind `pair` (JSON-stringified into CreateStrategyMessage.Size).
        The strategy always goes long its basket's weighted ratio
        exp(Σ ±wᵢ·ln pᵢ) (longs +, shorts −). Entry notional is
        margin_usd × leverage, split by weight. `markets` is filled by the
        server (the legs' perp display symbols).
      required: [sub, legs, margin_usd, leverage]
      properties:
        kind: { type: string, enum: [pair] }
        sub:
          type: string
          enum: [range, dca, zscore]
        legs:
          type: array
          minItems: 2
          maxItems: 30
          items: { $ref: '#/components/schemas/PairLeg' }
        markets:
          type: array
          readOnly: true
          items: { type: string }
        margin_usd: { type: number, exclusiveMinimum: 0, example: 100 }
        leverage: { type: integer, minimum: 1, maximum: 50, example: 2 }
        stop_margin_pct:
          type: number
          minimum: 0
          description: Protect stop as % of margin (sent to Pear as upnl_bps).
        tp_margin_pct:
          type: number
          minimum: 0
          description: Protect take profit as % of margin (upnl_bps).
        buy_level: { type: number, description: "range: open when the ratio falls to this level." }
        sell_level: { type: number, description: "range: take profit level (> buy_level)." }
        stop_level: { type: number, description: "range: stop level (< buy_level); 0 = none." }
        repeat: { type: boolean, description: "range: re-arm after a take profit once the ratio is back at buy_level." }
        start_level: { type: number, description: "dca: first add level." }
        adds: { type: integer, minimum: 1, maximum: 20, description: "dca: number of adds." }
        step_pct: { type: number, description: "dca: % between add levels." }
        growth: { type: number, minimum: 1, maximum: 3, description: "dca: add k is sized margin × growthᵏ." }
        stack_tp_pct: { type: number, description: "dca: stack take profit as % of margin (upnl_bps)." }
        restart: { type: boolean, description: "dca: after the take profit, re-arm one step below the exit." }
        first_add_now: { type: boolean, description: "dca: buy add 0 at market when the strategy starts (first cycle only; start_level is then the live ratio)." }
        window_hours: { type: integer, minimum: 24, maximum: 168, default: 168, description: "zscore: hourly history window." }
        z_entry: { type: number, description: "zscore: entry at mean ± z_entry·σ of the log ratio." }
        z_exit: { type: number, description: "zscore: exit at mean ± z_exit·σ (< z_entry)." }
        z_stop: { type: number, description: "zscore: stop at mean ± z_stop·σ (> z_entry); 0 = none." }
        market_entry: { type: boolean, description: "zscore: enter at market (write-ahead + reconcile) instead of resting triggers." }
        adopt_position_id:
          type: string
          format: uuid
          description: "Take over this existing open Pear position (same legs and direction) instead of opening one. The strategy pauses while Pear's auto-rebalance is on for it, and never opens a fresh position in its place. For dca the position counts as add 0 and start_level is the ratio it is handed over at. Not with first_add_now."
        trade_account_id:
          type: string
          format: uuid
          description: "The user's Pear trade account holding adopt_position_id, when it is not the one Pear automation was enabled for (e.g. Pear's own app). Every Pear call of the strategy goes to it."
      example:
        sub: range
        legs:
          - { coin: BTC, side: long, weight: 0.5 }
          - { coin: ETH, side: short, weight: 0.5 }
        margin_usd: 100
        leverage: 2
        buy_level: 20
        sell_level: 22
        stop_level: 18
        repeat: true

    Pagination:
      type: object
      required: [next_cursor, has_more]
      properties:
        next_cursor:
          type: string
          nullable: true
          description: Cursor to pass as `cursor` query parameter to fetch the next page. Null when no more pages.
          example: "MTcxODQwMDAwMDAwMC00Mg=="
        has_more:
          type: boolean
          description: Whether more pages are available.
          example: true

    Trade:
      type: object
      required: [source, market, issuer, base_market, market_type, timestamp, block_number, sequence, price, size, side, address, fee, realized_pnl, hash]
      properties:
        source:
          type: string
          example: hyperliquid
        market:
          type: string
          description: Full market identifier. Native HL perps use the base symbol (e.g. `BTC-PERP`); HIP-3 builder-deployed markets are namespaced as `<builder>:<base>` (e.g. `flx:GAS-PERP`).
          example: BTC-PERP
        issuer:
          type: string
          description: HIP-3 builder/issuer namespace. Empty string for native HL markets.
          example: ""
        base_market:
          type: string
          description: Base market symbol with any issuer namespace stripped. Equal to `market` for native HL perps.
          example: BTC-PERP
        market_type:
          type: string
          enum: [perp, spot]
          example: perp
        timestamp:
          type: integer
          format: int64
          description: Unix milliseconds.
          example: 1718400000000
        block_number:
          type: integer
          format: int64
          example: 12345678
        sequence:
          type: integer
          format: int64
          description: Monotonically increasing sequence number within the block.
          example: 42
        price:
          type: string
          description: Trade price as a decimal string.
          example: "67432.10"
        size:
          type: string
          description: Trade size as a decimal string.
          example: "1.5"
        side:
          type: string
          enum: [buy, sell]
          example: buy
        address:
          type: string
          description: Taker address for trades, account address for fills.
          example: "0xabc123"
        fee:
          type: string
          description: Fee paid in USD as a decimal string.
          example: "0.38"
        realized_pnl:
          type: string
          description: Realized PnL for this trade in USD. "0" for market trades.
          example: "0"
        hash:
          type: string
          description: Transaction hash (0x hex), shared by both sides of a trade. Recorded from 2026-08-01; empty on older rows and on TWAP fills.
          example: "0x9f1a2b"
        dir:
          type: string
          description: Hyperliquid's fill direction, such as "Open Long", "Close Short" or "Auto-Deleveraging". Recorded from 2026-08-01; omitted on older rows.
          example: "Open Long"
        venue:
          type: string
          description: Venue identifier (HIP-3 cutover, 2026-04-27).
          example: hyperliquid
        market_class:
          type: string
          enum: [perp, spot, prediction, option]
          description: Market class discriminator.
          example: perp
        market_id:
          type: integer
          format: int64
          description: Venue-native market id (HL asset id for perps).
          example: 0
        quote_asset:
          type: string
          description: Quote asset symbol.
          example: USD
        display_symbol:
          type: string
          description: Display symbol (same as `market`).
          example: BTC-PERP
        notional:
          type: string
          description: Notional value (`price * size`) of the fill.
          example: "101148.15"
        oid:
          type: integer
          format: int64
          description: HL order id.
          example: 9876543
        tid:
          type: integer
          format: int64
          description: HL trade id.
          example: 1234567
        fee_token:
          type: string
          description: Fee currency token.
          example: USDC
        liquidation:
          type: boolean
          description: True if this fill is the result of a liquidation.
          example: false

    TradeList:
      type: object
      required: [data, pagination]
      properties:
        notice:
          type: string
          description: Present when the answer needs a note, such as keyless data running 60 s behind, or fills served without an address.
        data:
          type: array
          items:
            $ref: '#/components/schemas/Trade'
        pagination:
          $ref: '#/components/schemas/Pagination'

    Candle:
      type: object
      required: [source, market, issuer, base_market, interval, timestamp, open, high, low, close, volume, trade_count]
      properties:
        source:
          type: string
          example: hyperliquid
        market:
          type: string
          description: Full market identifier. Echoes the `market` filter used in the request.
          example: BTC-PERP
        issuer:
          type: string
          description: Echoes the `issuer` filter used in the request. Empty if not filtered.
          example: ""
        base_market:
          type: string
          description: Echoes the `base_market` filter used in the request. Empty if not filtered.
          example: ""
        interval:
          type: string
          enum: [1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 8h, 12h, 1d, 3d, 1w]
          example: 1h
        timestamp:
          type: integer
          format: int64
          description: Start of the candle period, Unix milliseconds.
          example: 1718398800000
        open:
          type: string
          example: "67400.00"
        high:
          type: string
          example: "67500.50"
        low:
          type: string
          example: "67380.00"
        close:
          type: string
          example: "67432.10"
        volume:
          type: string
          description: Total traded volume in base asset units.
          example: "1523.45"
        trade_count:
          type: integer
          description: Number of trades in this candle.
          example: 8412

    CandleList:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Candle'
        pagination:
          $ref: '#/components/schemas/Pagination'

    FundingRate:
      type: object
      required: [source, market, issuer, base_market, timestamp, rate, sequence]
      properties:
        source:
          type: string
          example: hyperliquid
        market:
          type: string
          description: Full market identifier (`<builder>:<base>` for HIP-3, bare base for native HL).
          example: BTC-PERP
        issuer:
          type: string
          description: HIP-3 builder/issuer namespace. Empty string for native HL markets.
          example: ""
        base_market:
          type: string
          description: Base market symbol with any issuer namespace stripped.
          example: BTC-PERP
        timestamp:
          type: integer
          format: int64
          example: 1718398800000
        rate:
          type: string
          description: Funding rate as a decimal string. Positive = longs pay shorts. Negative = shorts pay longs.
          example: "0.0001"
        sequence:
          type: integer
          format: int64
          description: Monotonic sequence number for ordering events within a timestamp.
          example: 1
        venue:
          type: string
          description: Venue identifier. The funding_rates table is perp-only.
          example: hyperliquid
        market_id:
          type: integer
          format: int64
          example: 0
        quote_asset:
          type: string
          example: USD
        display_symbol:
          type: string
          example: BTC-PERP

    FundingRateList:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/FundingRate'
        pagination:
          $ref: '#/components/schemas/Pagination'

    OpenInterest:
      type: object
      required: [source, market, issuer, base_market, timestamp, open_interest, volume_24h]
      properties:
        source:
          type: string
          example: hyperliquid
        market:
          type: string
          description: Full market identifier (`<builder>:<base>` for HIP-3, bare base for native HL).
          example: BTC-PERP
        issuer:
          type: string
          description: HIP-3 builder/issuer namespace. Empty string for native HL markets.
          example: ""
        base_market:
          type: string
          description: Base market symbol with any issuer namespace stripped.
          example: BTC-PERP
        timestamp:
          type: integer
          format: int64
          example: 1718400000000
        open_interest:
          type: string
          description: Total open interest in base asset units.
          example: "45231.50"
        volume_24h:
          type: string
          description: |
            USD notional traded in this market in the 24 full hours before the
            snapshot's hour, from the hourly candles (`"0.00"` when nothing
            traded).
          example: "2136243889.23"
        venue:
          type: string
          description: Venue identifier. The open_interest table is perp-only.
          example: hyperliquid
        market_id:
          type: integer
          format: int64
          example: 0
        quote_asset:
          type: string
          example: USD
        display_symbol:
          type: string
          example: BTC-PERP

    OpenInterestList:
      type: object
      required: [data, pagination]
      properties:
        notice:
          type: string
          description: Present without a market, where the rows are the latest value of each market.
        data:
          type: array
          items:
            $ref: '#/components/schemas/OpenInterest'
        pagination:
          $ref: '#/components/schemas/Pagination'

    LiquidationList:
      type: object
      required: [data, pagination]
      properties:
        notice:
          type: string
          description: Present when the request needs attention, such as history shortened to your tier's window or a hint for pollers.
        data_starts_at:
          type: integer
          format: int64
          description: When history was shortened, the earliest time served (Unix ms).
        data:
          type: array
          items:
            $ref: '#/components/schemas/Liquidation'
        pagination:
          $ref: '#/components/schemas/Pagination'
        poll_cursor:
          type: string
          nullable: true
          description: Send back as `after=` for only rows newer than this page.
        complete_until:
          type: integer
          format: int64
          description: Unix ms up to which liquidations are final; rows after it may still arrive.

    LiquidationTotals:
      type: object
      properties:
        long_usd: { type: number, description: "Notional of long positions liquidated, USD." }
        short_usd: { type: number, description: "Notional of short positions liquidated, USD." }
        liquidations: { type: integer, description: "Liquidation events (one wallet, one market, one minute)." }
        wallets: { type: integer, description: "Distinct wallets liquidated." }

    LargestLiquidation:
      type: object
      properties:
        market: { type: string, example: BTC-PERP }
        side: { type: string, enum: [long, short], description: "The position that was closed." }
        usd: { type: number }
        price: { type: number, description: "Average fill price of the liquidation." }
        address: { type: string }
        timestamp: { type: integer, format: int64 }

    LiquidationSummary:
      type: object
      properties:
        as_of: { type: integer, format: int64 }
        windows:
          type: array
          items:
            type: object
            properties:
              window: { type: string, example: 24h }
              from: { type: integer, format: int64 }
              total: { $ref: '#/components/schemas/LiquidationTotals' }
              largest: { $ref: '#/components/schemas/LargestLiquidation' }
              markets:
                type: array
                description: Markets with liquidations in the window, largest first.
                items:
                  allOf:
                    - $ref: '#/components/schemas/LiquidationTotals'
                    - type: object
                      properties:
                        market: { type: string }
                        largest: { $ref: '#/components/schemas/LargestLiquidation' }
        series:
          type: object
          description: With bucket, per market, the long and short USD and count per step over the longest window.
          additionalProperties:
            type: array
            items:
              type: object
              properties:
                timestamp: { type: integer, format: int64 }
                long_usd: { type: number }
                short_usd: { type: number }
                liquidations: { type: integer }

    Liquidation:
      type: object
      required: [source, market, issuer, base_market, market_type, timestamp, block_number, address, side, price, size, account_value, leverage_type, bankrupt, sequence]
      properties:
        source: { type: string, example: hyperliquid }
        market: { type: string, description: "Full market identifier (`<builder>:<base>` for HIP-3, bare base for native HL).", example: BTC-PERP }
        issuer: { type: string, description: "HIP-3 builder/issuer namespace. Empty string for native HL markets.", example: "" }
        base_market: { type: string, description: "Base market symbol with any issuer namespace stripped.", example: BTC-PERP }
        market_type: { type: string, enum: [perp, spot], example: perp }
        timestamp: { type: integer, format: int64, example: 1718400000000 }
        block_number: { type: integer, format: int64, example: 12345678 }
        address:
          type: string
          description: Address of the liquidated account.
          example: "0xabc123"
        liquidator:
          type: string
          description: Address of the liquidator (optional).
          example: "0xdef456"
        side:
          type: string
          enum: [buy, sell]
          description: Side of the liquidated position.
          example: buy
        price: { type: string, description: "Liquidation price.", example: "67380.00" }
        size: { type: string, description: "Position size that was liquidated.", example: "2.5" }
        realized_pnl: { type: string, description: "Realized PnL on the liquidation (optional).", example: "-1200.00" }
        account_value: { type: string, description: "Account value at liquidation.", example: "0.00" }
        margin_requirement: { type: string, description: "Margin requirement at liquidation (optional).", example: "1000.00" }
        leverage_type: { type: string, description: "isolated or cross.", example: isolated }
        bankrupt: { type: boolean, description: "True if the account was bankrupted by this liquidation.", example: false }
        hash: { type: string, description: "Transaction hash (optional).", example: "0x..." }
        detection_method: { type: string, enum: [native, heuristic], description: "How the liquidation was detected.", example: native }
        sequence:
          type: integer
          format: int64
          description: Monotonic sequence number for ordering events within a timestamp.
          example: 1
        venue:
          type: string
          description: Venue identifier. The liquidations table is perp-only.
          example: hyperliquid
        market_id:
          type: integer
          format: int64
          example: 0
        quote_asset:
          type: string
          example: USD
        display_symbol:
          type: string
          example: BTC-PERP

    MarkPrice:
      type: object
      required: [source, market, issuer, base_market, timestamp, mark_price, oracle_price, funding_rate]
      properties:
        source:
          type: string
          example: hyperliquid
        market:
          type: string
          description: Full market identifier (`<builder>:<base>` for HIP-3, bare base for native HL).
          example: BTC-PERP
        issuer:
          type: string
          description: HIP-3 builder/issuer namespace. Empty string for native HL markets.
          example: ""
        base_market:
          type: string
          description: Base market symbol with any issuer namespace stripped.
          example: BTC-PERP
        timestamp:
          type: integer
          format: int64
          example: 1718400000000
        mark_price:
          type: string
          example: "67432.10"
        oracle_price:
          type: string
          description: Oracle price from the same poll as the mark. `"0"` before 2026-09-04, when the oracle series started.
          example: "67440.50"
        funding_rate:
          type: string
          description: Always `"0"`; read funding rates from `/v1/funding`.
          example: "0"
        venue:
          type: string
          description: Venue identifier. The mark_prices table is perp-only.
          example: hyperliquid
        market_id:
          type: integer
          format: int64
          example: 0
        quote_asset:
          type: string
          example: USD
        display_symbol:
          type: string
          example: BTC-PERP

    MarkPriceList:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/MarkPrice'
        pagination:
          $ref: '#/components/schemas/Pagination'

    VaultOperation:
      type: object
      required: [source, issuer, base_market, market_type, timestamp, block_number, address, vault, operation, token, requested_amount, net_amount, commission, closing_cost, basis, sequence]
      properties:
        source: { type: string, example: hyperliquid }
        issuer: { type: string, description: "HIP-3 builder/issuer namespace. Empty string for native HL markets.", example: "" }
        base_market: { type: string, description: "Base market symbol with any issuer namespace stripped. Empty when the operation is not scoped to a market.", example: "" }
        market_type: { type: string, enum: [perp, spot], example: perp }
        timestamp: { type: integer, format: int64, example: 1718400000000 }
        block_number: { type: integer, format: int64, example: 12345678 }
        address: { type: string, example: "0xabc123" }
        vault: { type: string, example: "0xvault123" }
        operation: { type: string, enum: [deposit, withdraw], example: deposit }
        token: { type: string, example: USDC }
        requested_amount: { type: string, example: "10000.00" }
        net_amount: { type: string, description: "Amount applied after commission/closing cost.", example: "9990.00" }
        commission: { type: string, description: "Commission paid (withdraw only, otherwise 0).", example: "10.00" }
        closing_cost: { type: string, description: "Closing cost on partial exits.", example: "0.00" }
        basis: { type: string, description: "Cost basis for the operation.", example: "9985.50" }
        tx_hash: { type: string, description: "On-chain transaction hash (optional).", example: "0x..." }
        sequence: { type: integer, format: int64, description: "Monotonic sequence number.", example: 1 }

    VaultOperationList:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/VaultOperation' }
        pagination: { $ref: '#/components/schemas/Pagination' }

    NetFlow:
      type: object
      required: [timestamp, deposits, withdrawals, net]
      properties:
        timestamp: { type: integer, format: int64, example: 1718400000000 }
        deposits: { type: string, example: "1500000.00" }
        withdrawals: { type: string, example: "800000.00" }
        net: { type: string, example: "700000.00" }

    LiquidationLevel:
      type: object
      required: [price_bucket, count, total_size]
      properties:
        market: { type: string, example: BTC-PERP }
        price_bucket: { type: string, example: "75200", description: Lower edge of the price bucket }
        side: { type: string, example: sell, description: Side of the liquidation fill. A liquidated long is closed with a sell }
        count: { type: integer, example: 15 }
        total_size: { type: string, example: "45.2", description: Liquidated size in the coin; multiply by the price for USD }
        bucket_size: { type: string, example: "200" }

    LiquidationMap:
      type: object
      required: [market, mark_px, as_of, snapshot, bucket_size, range_pct, side, levels, coverage, stale, method]
      properties:
        notice:
          type: string
          description: Present on keyless calls, which return a sample, and when bucket_size was raised.
        market: { type: string, example: BTC-PERP }
        mark_px: { type: number, example: 75782, description: The mark the liquidation prices were computed against }
        as_of: { type: string, format: date-time, example: "2026-11-06T16:48:30Z", description: When the positions were last priced }
        snapshot:
          type: object
          required: [height, time, age_s]
          description: The node state snapshot the positions come from.
          properties:
            height: { type: integer, format: int64, example: 1211490000, description: Block height of the snapshot }
            time: { type: string, format: date-time, example: "2026-11-06T16:47:28Z" }
            age_s: { type: integer, example: 62, description: Seconds since the snapshot, counted when this answer was made }
        bucket_size: { type: number, example: 200 }
        range_pct: { type: number, example: 20, description: The range covered, in percent of mark each way }
        side: { type: string, enum: [both, long, short] }
        levels:
          type: array
          description: Price buckets holding positions, from the lowest price to the highest.
          items: { $ref: '#/components/schemas/LiquidationMapLevel' }
        coverage:
          type: object
          description: How much of the market's open notional the map prices, and how.
          properties:
            notional_usd: { type: number, example: 3286600394, description: Open notional of the market, both sides }
            with_liq_px_pct: { type: number, example: 98.1, description: Share of it that has a liquidation price }
            portfolio_margin_pct: { type: number, example: 6.0, description: Share priced by Hyperliquid's own liquidationPx (portfolio-margin accounts) }
            unknown_mode_pct: { type: number, example: 0.5, description: Share from accounts whose margin mode is not known yet }
        stale: { type: boolean, description: True when the map service did not answer and an older map is returned }
        method: { type: string, description: How the map is made }
      example:
        market: BTC-PERP
        mark_px: 75782
        as_of: "2026-11-06T16:48:30Z"
        snapshot: { height: 1211490000, time: "2026-11-06T16:47:28Z", age_s: 62 }
        bucket_size: 200
        range_pct: 20
        side: both
        levels:
          - { price_lo: 72600, price_hi: 72800, long_usd: 21480000, long_positions: 412, short_usd: 0, short_positions: 0, cum_long_usd: 208900000, cum_short_usd: 0 }
          - { price_lo: 78800, price_hi: 79000, long_usd: 0, long_positions: 0, short_usd: 9800000, short_positions: 61, cum_short_usd: 62400000, cum_long_usd: 0 }
        coverage: { notional_usd: 3286600394, with_liq_px_pct: 98.1, portfolio_margin_pct: 6.0, unknown_mode_pct: 0.5 }
        stale: false
        method: "Every open position from our node's state snapshot. Liquidation prices follow Hyperliquid's maintenance margin rules; for cross-margin positions they assume the account's other positions stay at mark. Portfolio-margin accounts use Hyperliquid's own liquidationPx. Notional is size times liquidation price. In a real move correlated markets fall together, so cross-margin accounts are liquidated earlier than these prices."

    LiquidationMapLevel:
      type: object
      required: [price_lo, price_hi, long_usd, long_positions, short_usd, short_positions, cum_long_usd, cum_short_usd]
      properties:
        price_lo: { type: number, example: 72600 }
        price_hi: { type: number, example: 72800 }
        long_usd: { type: number, example: 21480000, description: Notional of longs liquidated in this range (size times liquidation price) }
        long_positions: { type: integer, example: 412 }
        short_usd: { type: number, example: 0 }
        short_positions: { type: integer, example: 0 }
        cum_long_usd: { type: number, example: 208900000, description: Longs liquidated from the mark down to this level }
        cum_short_usd: { type: number, example: 0, description: Shorts liquidated from the mark up to this level }

    LiquidationMapMarkets:
      type: object
      required: [as_of, markets, stale]
      properties:
        as_of: { type: string, format: date-time, example: "2026-11-06T16:48:30Z" }
        markets:
          type: array
          items:
            type: object
            required: [market, mark_px, long_usd, short_usd, positions]
            properties:
              market: { type: string, example: BTC-PERP }
              mark_px: { type: number, example: 75782 }
              long_usd: { type: number, example: 1643300197, description: Long notional with a liquidation price }
              short_usd: { type: number, example: 1643300197, description: Short notional with a liquidation price }
              positions: { type: integer, example: 41608 }
        stale: { type: boolean }

    VaultLeaderboardEntry:
      type: object
      required: [vault, net_deposits, deposit_count, withdraw_count]
      properties:
        vault: { type: string, example: "0xvault123" }
        net_deposits: { type: string, example: "5000000.00" }
        deposit_count: { type: integer, example: 42 }
        withdraw_count: { type: integer, example: 8 }

    FundingStats:
      type: object
      required: [timestamp, rate, annualized]
      properties:
        timestamp: { type: integer, format: int64, example: 1718400000000 }
        rate: { type: string, example: "0.0001" }
        annualized: { type: string, example: "0.0876" }

    # ─── Per-address wallet analytics schemas (v0.31.0+) ──────────────────

    UserState:
      type: object
      description: Snapshot of an address's current open positions + uPnL.
      required: [address, as_of_ts, asset_positions, margin_summary]
      properties:
        address: { type: string, example: "0x0c684f333a7e120bce61383da670bbb0157e82d0" }
        as_of_ts: { type: integer, format: int64, description: "Unix ms of the latest mark_price used", example: 1779394567000 }
        asset_positions:
          type: array
          items: { $ref: '#/components/schemas/AssetPosition' }
        margin_summary: { $ref: '#/components/schemas/UserMarginSummary' }

    AssetPosition:
      type: object
      description: One open market position. `size` is signed (positive = long).
      required: [market, market_class, size, entry_price, mark_price, position_value, unrealized_pnl, realized_pnl]
      properties:
        market: { type: string, example: "BTC-PERP" }
        market_class: { type: string, enum: [perp, spot, prediction, option], example: "perp" }
        size: { type: number, format: double, example: 0.1234, description: "Signed; positive = long, negative = short" }
        entry_price: { type: number, format: double, example: 78000.0 }
        mark_price: { type: number, format: double, example: 78500.0 }
        position_value: { type: number, format: double, example: 9690.0 }
        unrealized_pnl: { type: number, format: double, example: 61.7 }
        realized_pnl: { type: number, format: double, description: "Lifetime cumulative", example: 150.0 }
        return_on_equity: { type: number, format: double, example: 0.00637 }
        leverage: { type: number, format: double, description: "Position leverage multiplier from HL clearinghouseState; omitted when unavailable", example: 10 }
        leverage_type: { type: string, enum: [cross, isolated], description: "HL leverage mode; omitted when unavailable", example: "cross" }

    UserMarginSummary:
      type: object
      required: [total_position_value, total_unrealized_pnl, total_realized_pnl, open_markets_count]
      properties:
        total_position_value: { type: number, format: double, example: 9690.0 }
        total_unrealized_pnl: { type: number, format: double, example: 61.7 }
        total_realized_pnl: { type: number, format: double, example: 150.0 }
        open_markets_count: { type: integer, example: 1 }

    UserFundingPayment:
      type: object
      description: One bucket of funding payments. `funding_payment` is signed — positive = received, negative = paid.
      required: [bucket_start, market, market_class, funding_payment, avg_position, avg_funding_rate, interval_count]
      properties:
        bucket_start: { type: integer, format: int64, description: "Unix ms — bucket boundary (UTC)", example: 1779302400000 }
        market: { type: string, example: "BTC-PERP" }
        market_class: { type: string, enum: [perp], example: "perp" }
        funding_payment: { type: number, format: double, description: "Signed USDC; + received, - paid", example: 5.23 }
        avg_position: { type: number, format: double, description: "Mean signed position over the bucket", example: 0.1 }
        avg_funding_rate: { type: number, format: double, example: 0.0000125 }
        interval_count: { type: integer, description: "Number of funding intervals in the bucket", example: 24 }

    UserMakerTakerBreakdown:
      type: object
      required: [market, market_class, maker_fills, taker_fills, maker_notional, taker_notional, maker_fee, taker_fee, total_fills, pct_maker]
      properties:
        market: { type: string, example: "HYPE-PERP" }
        market_class: { type: string, enum: [perp, spot, prediction, option], example: "perp" }
        maker_fills: { type: integer, format: int64, example: 1234, description: "Fills where the address provided liquidity (crossed=false)" }
        taker_fills: { type: integer, format: int64, example: 567, description: "Fills where the address crossed the spread (crossed=true)" }
        maker_notional: { type: number, format: double, example: 1000000.0 }
        taker_notional: { type: number, format: double, example: 500000.0 }
        maker_fee: { type: number, format: double, description: "Sum of fee on maker fills (often negative = rebate)", example: -50.0 }
        taker_fee: { type: number, format: double, example: 200.0 }
        total_fills: { type: integer, format: int64, example: 1801 }
        pct_maker: { type: number, format: double, description: "[0, 1]", example: 0.685 }

    UserLedgerEntry:
      type: object
      required: [ts, type, token, amount]
      properties:
        ts: { type: integer, format: int64, description: "Unix ms", example: 1779302400000 }
        type:
          type: string
          description: "Event type discriminator"
          enum:
            - deposit
            - withdrawal
            - vault_deposit
            - vault_withdraw
            - vault_transfer_in
            - vault_transfer_out
            - vault_distribute
            - vault_commission
            - vault_create
            - send
            - spot_send
            - internal_transfer
            - account_class_transfer
            - rewards_claim
            - delegate
            - undelegate
            - sub_account_transfer
            - c_deposit
            - c_withdraw
            - other
        token: { type: string, example: "USDC" }
        amount: { type: number, format: double, description: "Signed: + into account, - out", example: 1000.0 }
        tx_hash: { type: string, example: "0x...", nullable: true }
        details:
          type: object
          additionalProperties: true
          description: "Type-specific fields (e.g. to_perp for account_class_transfer, validator/wei for delegate/undelegate)"

    # ─── Cross-wallet ranking schemas (v0.34+) ────────────────────────────

    LeaderboardEntry:
      type: object
      description: One entry in the pre-aggregated public leaderboard (Bet 5).
      required: [rank, address, realized_pnl, win_rate, trade_count, avg_trade_size, top_market]
      properties:
        rank: { type: integer, description: "1-based rank within the window", example: 1 }
        address: { type: string, example: "0xabc123def456789abcdef0123456789abcdef01" }
        realized_pnl: { type: number, format: double, example: 125000.50 }
        win_rate: { type: number, format: double, description: "wins / (wins + losses), 0..1", example: 0.62 }
        trade_count: { type: integer, example: 847 }
        avg_trade_size: { type: number, format: double, description: "Average fill notional USD", example: 4800.00 }
        top_market: { type: string, description: "Market with highest absolute realized PnL", example: "BTC-PERP" }

    PnLLeaderboardRow:
      type: object
      description: One ranked wallet on the global leaderboard.
      required: [address, realized_pnl, volume_usd, fills, wins, losses, win_rate, maker_fills, taker_fills, pct_maker, markets_count]
      properties:
        address: { type: string, example: "0x96de02546bde61e6a62e3f242ea4c55265311445" }
        realized_pnl: { type: number, format: double, example: 3487661.29 }
        volume_usd: { type: number, format: double, example: 18187323.56 }
        fills: { type: integer, format: int64, example: 4680 }
        wins: { type: integer, format: int64, description: "Fills where realized_pnl > 0", example: 3936 }
        losses: { type: integer, format: int64, description: "Fills where realized_pnl < 0", example: 0 }
        win_rate: { type: number, format: double, description: "wins / (wins + losses)", example: 1.0 }
        maker_fills: { type: integer, format: int64, example: 4678 }
        taker_fills: { type: integer, format: int64, example: 2 }
        pct_maker: { type: number, format: double, description: "maker_fills / fills", example: 0.9996 }
        markets_count: { type: integer, format: int64, description: "Distinct markets traded in window", example: 1 }

    MarketTopWalletRow:
      type: object
      description: One ranked wallet for a single market.
      required: [address, volume_usd, fills, realized_pnl, pct_maker]
      properties:
        address: { type: string }
        volume_usd: { type: number, format: double }
        fills: { type: integer, format: int64 }
        realized_pnl: { type: number, format: double }
        pct_maker: { type: number, format: double, description: "maker_fills / fills" }

    MarketSnapshotRow:
      type: object
      description: Anchored market state — single-call replacement for N×3 fan-out.
      required: [market_id, display_symbol, market_class, mark_price]
      properties:
        market_id: { type: integer, example: 0 }
        display_symbol: { type: string, example: "BTC-PERP" }
        market_class: { type: string, enum: [perp, spot, prediction, option] }
        issuer: { type: string, description: "HIP-3 builder code, empty for native", example: "" }
        mark_price: { type: number, format: double }
        mark_24h_ago: { type: number, format: double, nullable: true }
        change_24h_pct: { type: number, format: double, nullable: true, description: "Decimal (0.0123 = +1.23%)" }
        open_interest_base: { type: number, format: double, nullable: true, description: "Base units (e.g. BTC for BTC-PERP)" }
        open_interest_usd: { type: number, format: double, nullable: true, description: "OI converted via current mark" }
        funding_rate: { type: number, format: double, nullable: true, description: "Latest funding (1h)" }
        mark_ts_ms: { type: integer, format: int64, nullable: true }
        volume_24h_usd:
          type: number
          format: double
          nullable: true
          description: |
            Notional traded over the last 24h, USD. Computed from buy-side
            fills only (HL emits two fills per trade — filtering to
            side='buy' prevents double-counting).
          example: 1843200000.0

    WalletLabel:
      type: object
      description: One label attached to an address by the precomputed catalog.
      required: [kind, name, priority]
      properties:
        kind:
          type: string
          enum: [whale, smart_money, vault, vault_leader]
        rank:
          type: integer
          description: "1-based rank within the category (0 for unranked kinds)"
          example: 5
        name:
          type: string
          description: "Human-readable, stable display string."
          example: "WHALE · #5"
        priority:
          type: integer
          description: "Higher wins when an address has multiple labels."
          example: 30
        value:
          type: number
          format: double
          nullable: true
          description: "Metric value that earned the label (e.g. 24h USD volume)."

    WalletLabelsResponse:
      type: object
      required: [generated_at, ttl_seconds, source, labels, counts]
      properties:
        generated_at: { type: integer, format: int64, description: "Unix ms" }
        ttl_seconds: { type: integer, example: 300 }
        source: { type: string, example: "hyperliquid" }
        labels:
          type: object
          description: "Map of address → label list (lowercase hex keys)."
          additionalProperties:
            type: array
            items: { $ref: '#/components/schemas/WalletLabel' }
        counts:
          type: object
          required: [whales, smart_money, vaults, vault_leaders]
          properties:
            whales: { type: integer }
            smart_money: { type: integer }
            vaults: { type: integer }
            vault_leaders: { type: integer }

    UserSummary:
      type: object
      description: Aggregate stats for one address over a window. Single CH aggregation.
      required: [address, from, to, fills, volume_usd, realized_pnl]
      properties:
        address: { type: string }
        from: { type: integer, format: int64 }
        to: { type: integer, format: int64 }
        fills: { type: integer, format: int64 }
        volume_usd: { type: number, format: double }
        realized_pnl: { type: number, format: double }
        wins: { type: integer, format: int64 }
        losses: { type: integer, format: int64 }
        win_rate: { type: number, format: double }
        maker_fills: { type: integer, format: int64 }
        taker_fills: { type: integer, format: int64 }
        pct_maker: { type: number, format: double }
        markets_count: { type: integer, format: int64 }
        first_fill_ts: { type: integer, format: int64, nullable: true }
        last_fill_ts: { type: integer, format: int64, nullable: true }

    UserPnLSeriesPoint:
      type: object
      description: One time-bucket of realized PnL for a wallet.
      required: [bucket_start, realized_pnl]
      properties:
        bucket_start: { type: integer, format: int64, description: "Unix ms — bucket boundary" }
        realized_pnl: { type: number, format: double, description: "Sum of realized_pnl in bucket" }

    CohortInfo:
      type: object
      required: [name]
      properties:
        name: { type: string, example: high_volume_30d }
        description: { type: string, description: "Human-readable description of the cohort.", example: "Top 100 addresses by trading volume over the last 30 days" }

    DataFreshness:
      type: object
      required: [latest_timestamp, age_seconds, total_rows]
      properties:
        latest_timestamp:
          type: string
          description: Timestamp of the most recent row as ClickHouse DateTime64 string.
          example: "2026-04-20 09:58:53.033"
        age_seconds:
          type: number
          description: Seconds since the latest row was written.
          example: 0.41
        total_rows:
          type: integer
          format: int64
          description: Total rows currently stored for this table (respecting TTL).
          example: 1843000000

    VenueStatus:
      type: object
      required: [status, markets, streams, events]
      properties:
        status:
          type: string
          enum: [operational, degraded]
        markets:
          type: integer
          format: int64
          description: Markets seen in fills over the last 24h.
        streams:
          type: object
          description: High-frequency streaming tables (e.g. fills, order_statuses, book_snapshots).
          additionalProperties:
            $ref: '#/components/schemas/DataFreshness'
        events:
          type: object
          description: Event-driven tables where gaps are normal (e.g. liquidations, funding_rates).
          additionalProperties:
            $ref: '#/components/schemas/DataFreshness'

    PlatformStats:
      type: object
      required: [total_events, total_markets, event_types_active, oldest_data_days]
      properties:
        total_events: { type: integer, format: int64 }
        total_markets: { type: integer, format: int64 }
        event_types_active: { type: integer }
        oldest_data_days: { type: integer }

    MarketsResponse:
      type: object
      required: [data, count]
      properties:
        count:
          type: integer
          description: Number of rows in `data`.
        data:
          type: array
          items:
            $ref: '#/components/schemas/MarketEntry'
        pagination:
          type: object
          description: Present only when the request set `limit`.
          required: [next_cursor, has_more, total]
          properties:
            next_cursor:
              type: string
              nullable: true
              description: Pass back as `cursor` for the next page; null on the last page.
            has_more:
              type: boolean
            total:
              type: integer
              description: Number of markets matching the filters, across all pages.

    MarketEntry:
      type: object
      required:
        [
          market_id,
          source,
          market_class,
          issuer,
          base_asset,
          quote_asset,
          base_market,
          display_symbol,
          active_from_ms,
        ]
      properties:
        market_id:
          type: integer
          format: int64
          description: Stable integer ID assigned by the registry. Identity column.
        source:
          type: string
          description: Venue (e.g. `hyperliquid`).
        market_class:
          type: string
          enum: [perp, spot, prediction, option]
        issuer:
          type: string
          description: HIP-3 builder code, or empty for native non-builder markets.
        base_asset:
          type: string
          description: Underlying asset symbol (e.g. `BTC`, `GAS`, `HFUN`).
        quote_asset:
          type: string
          description: Quote asset symbol (typically `USDC`).
        base_market:
          type: string
          description: |
            Display symbol with any HIP-3 issuer prefix stripped. Equal to
            `display_symbol` for native markets; e.g. `flx:GAS-PERP` →
            `GAS-PERP`. Use with `issuer` to scope cross-builder queries.
        display_symbol:
          type: string
          description: Symbol shown to end users (e.g. `BTC-PERP`, `flx:GAS-PERP`, `HFUN/USDC`).
        hl_coin:
          type: string
          description: Raw venue wire-form coin string (`BTC`, `flx:GAS`, `@132`). Optional.
        active_from_ms:
          type: integer
          format: int64
          description: Activation timestamp as Unix milliseconds.
        active_to_ms:
          type: integer
          format: int64
          description: When the market closed (delisted, or a HIP-4 outcome settled), as Unix milliseconds. Only on rows returned with `include_inactive=true`.

    StatusMetricsResponse:
      type: object
      required: [timestamp, api, pipeline, synthetic]
      properties:
        timestamp:
          type: integer
          format: int64
          description: Server time as Unix milliseconds.
        api:
          type: object
          required: [latency_p50_ms, latency_p99_ms, error_rate, refusal_rate, websocket_clients]
          properties:
            latency_p50_ms:
              type: number
              description: 5-minute windowed p50 request latency (milliseconds).
            latency_p99_ms:
              type: number
              description: 5-minute windowed p99 request latency (milliseconds).
            error_rate:
              type: number
              description: 5-minute windowed share of 4xx+5xx responses that are failures (0.0 to 1.0). Refusals are left out and counted in refusal_rate.
            refusal_rate:
              type: number
              description: '5-minute windowed share of responses that are refusals rather than failures (0.0 to 1.0): keyless daily-limit refusals (KEYLESS_DAILY_LIMIT) and unpaid x402 payment asks.'
            websocket_clients:
              type: integer
              description: Live WebSocket connection count.
        pipeline:
          type: object
          required: [streaming_latency_ms, events_per_sec]
          properties:
            streaming_latency_ms:
              type: number
              description: Tailer publish latency (event arrival → NATS) in milliseconds.
            events_per_sec:
              type: number
              description: Tailer NATS publish rate.
        synthetic:
          type: object
          required: [ws_latency_ms, ws_latency_p50_ms, query_latencies]
          properties:
            ws_latency_ms:
              type: number
              description: Latest WebSocket round-trip from the synthetic probe (ms).
            ws_latency_p50_ms:
              type: number
              description: Synthetic probe p50 WebSocket latency (ms).
            query_latencies:
              type: object
              description: Latest synthetic query latencies, keyed by endpoint name (ms).
              additionalProperties:
                type: number

    StatusHistoryResponse:
      type: object
      required: [points, uptime_percent, period]
      properties:
        period:
          type: string
          enum: [24h, 7d]
        uptime_percent:
          type: number
          description: Share of buckets in `operational` state (0–100).
        points:
          type: array
          items:
            $ref: '#/components/schemas/UptimePoint'

    UptimePoint:
      type: object
      required: [ts, status, fresh_sec]
      properties:
        ts:
          type: integer
          format: int64
          description: Bucket timestamp as Unix milliseconds.
        status:
          type: string
          enum: [operational, degraded, down]
        fresh_sec:
          type: number
          description: Maximum data-freshness across high-frequency tables in this bucket (seconds).

    HealthResponse:
      type: object
      required: [status]
      properties:
        status:
          type: string
          enum: [ok]
          description: Always `"ok"` when the server is alive enough to answer.
      example:
        status: ok

    StatusResponse:
      type: object
      required: [status, timestamp, uptime, venues]
      properties:
        status:
          type: string
          enum: [operational, degraded, down]
          description: |
            Overall system health, the worst of any individual service or venue.
            `degraded` means at least one input crossed 60s of staleness;
            `down` means at least one crossed 5 minutes (or a process is hard-down).
        timestamp:
          type: integer
          format: int64
          description: Server time as Unix milliseconds.
        uptime:
          type: string
          description: |
            Time since the last pipeline incident (data freshness > 60s OR a
            critical process scraped as down) within the trailing 7-day window,
            as a Go duration string (e.g. `"3h12m40s"`, `"168h0m0s"` for a full
            incident-free week). Sourced from Prometheus, so it is stable across
            api-server restarts — rolling deploys do NOT reset this value.
            If Prometheus is unreachable, falls back to the process uptime of
            the serving container as a degraded-mode best-effort value.
        platform:
          $ref: '#/components/schemas/PlatformStats'
        venues:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/VenueStatus'
        components:
          type: object
          description: |
            Health of each customer-facing service. Three keys are returned:

              - `data_pipeline` — events ingested from venues into queryable storage.
                Reflects worst-of(ingest path liveness, venue freshness).
              - `rest_api`      — HTTP API for historical and recent data.
              - `streaming`     — the live Server-Sent Events streams.

            The status page deliberately exposes services rather than the
            underlying processes — internal implementation components
            (message bus, storage engine, individual binaries) are folded
            into these three to give customers a clear answer without
            leaking the architecture.
          additionalProperties:
            $ref: '#/components/schemas/ComponentHealth'

    ComponentHealth:
      type: object
      required: [status, last_seen_age_seconds]
      properties:
        status:
          type: string
          enum: [operational, degraded, down]
        last_seen_age_seconds:
          type: number
          format: double
          description: |
            Age in seconds of the most-recent successful liveness signal across
            this service's inputs. `-1` means no signal at all (treat as down).

    BillingPlan:
      type: object
      required: [id, name, price_usdc, requests_per_minute, history_days]
      properties:
        id: { type: string, enum: [explorer, builder, founding] }
        name: { type: string }
        price_usdc: { type: number, description: Price per 30 days }
        requests_per_minute: { type: integer }
        history_days: { type: integer }
        x402_buy_url: { type: string, format: uri, description: Buy it in one x402 call (USDC on Base) }
        seats_left: { type: integer, description: Founding offer only }
        about: { type: string, description: Founding offer only }

    PaymentNetwork:
      type: object
      required: [id, name, token, to_address, steps]
      properties:
        id: { type: string, enum: [hyperliquid, base] }
        name: { type: string }
        token: { type: string, enum: [USDC] }
        to_address: { type: string, description: Ironflow's receiving address on this network }
        chain_id: { type: integer, description: On Base only (8453) }
        token_contract: { type: string, description: On Base only, native USDC }
        confirmations_required: { type: integer, description: On Base only }
        steps:
          type: array
          items: { type: string }

    UpgradeRequest:
      type: object
      required: [request_id, status, plan, amount_usdc, to_address, from_wallet]
      properties:
        request_id: { type: string, description: '`up_` and 32 hex characters' }
        status: { type: string, enum: [pending, paid, expired] }
        status_url: { type: string, format: uri }
        page_url: { type: string, format: uri }
        email: { type: string }
        plan: { type: string }
        plan_name: { type: string }
        amount_usdc: { type: number }
        days: { type: integer }
        token: { type: string, enum: [USDC] }
        network: { type: string, enum: [Hyperliquid, Base] }
        chain_id: { type: integer, description: On Base only (8453) }
        token_contract: { type: string, description: On Base only, native USDC }
        confirmations_required: { type: integer, description: On Base only, blocks a transfer must be deep before it counts }
        from_wallet: { type: string }
        to_address: { type: string }
        expires_at: { type: string, format: date-time, description: The request lapses unpaid after 7 days }
        checked_every_seconds: { type: integer }
        message: { type: string }
      example:
        request_id: up_0123456789abcdef0123456789abcdef
        status: pending
        status_url: https://api.ironflow.sh/v1/billing/upgrade?request=up_0123456789abcdef0123456789abcdef
        page_url: https://ironflow.sh/upgrade?request=up_0123456789abcdef0123456789abcdef
        email: dev@example.com
        plan: explorer
        plan_name: Explorer
        amount_usdc: 49
        days: 30
        token: USDC
        network: Hyperliquid
        from_wallet: '0x1234567890abcdef1234567890abcdef12345678'
        to_address: '0x0000000000000000000000000000000000000000'
        expires_at: '2026-11-14T09:00:00Z'
        checked_every_seconds: 10

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Machine-readable error code.
              example: RATE_LIMIT_EXCEEDED
            message:
              type: string
              description: Human-readable error description.
              example: Rate limit exceeded. Retry after 60 seconds.
            upgrade:
              type: object
              description: On a limit a higher plan lifts, the next plan, its price, what it lifts and the call that gets it.
              properties:
                plan: { type: string, example: explorer }
                price_usdc: { type: number, example: 49 }
                days: { type: integer, example: 30 }
                unlocks: { type: string, description: 'What the plan adds, starting with SQL export to Parquet or CSV' }
                limit: { type: string, description: 'The limit this error hit: rate, history, window, sql_budget, read_budget, streams, alerts, keyless_day or tier', example: rate }
                lifts: { type: string, description: What the plan changes about that limit, example: '60 requests a minute now, 100 on Explorer' }
                url: { type: string, format: uri, description: Where to upgrade; the link carries the plan and the limit }
                how: { type: string, description: The API call that opens the upgrade request }
                buy_url: { type: string, format: uri, description: Buy this plan in one x402 call (USDC on Base), when x402 is on }
                pay_per_call: { type: object, description: On limits one paid call lifts, how to pay for this call with x402 }
                offer: { type: string, description: Founding-member offer, while seats remain }
            get_key:
              type: object
              description: On every keyless 429 (daily cap, per-minute rate, SQL and read budgets, stream cap) and keyless history refusal, the one call that gets a free key. `page` is the key page tagged with the refusal (utm_campaign).
              properties:
                method: { type: string, example: POST }
                url: { type: string, format: uri, example: "https://api.ironflow.sh/v1/keys" }
                body: { type: object, additionalProperties: { type: string }, example: { email: you@example.com } }
                page: { type: string, format: uri }
            dataset:
              type: object
              description: On `HISTORY_OUT_OF_RANGE`, and on a rate limit for a closed past range, the free download that holds the requested rows, when one does. The message repeats it with its dates.
              properties:
                name: { type: string, example: Hyperliquid Liquidations }
                url: { type: string, format: uri, example: "https://huggingface.co/datasets/ironflow/hyperliquid-liquidations?utm_source=api" }
                holds: { type: string, example: every liquidation }
                from: { type: string, format: date, example: "2025-05-25" }
                through: { type: string, format: date, example: "2026-10-31" }
            ironbox:
              type: object
              description: On a rate limit, the keyless daily cap, the stream limit, the SQL budget and the read budget, how to run the agent in an Ironbox instead.
              properties:
                why: { type: string, example: "No rate limits inside an Ironbox: your agent runs next to Hyperliquid with the data on the box." }
                create: { type: string, example: "POST https://api.ironflow.sh/v1/ironbox" }
                url: { type: string, format: uri, example: "https://ironflow.sh/ironbox?utm_source=api" }
            stream:
              type: string
              description: On the keyless daily limit, a per-minute rate limit, the read budget and a keyless history refusal of `/v1/liquidations` and `/v1/trades`, the live stream that replaces polling.
              example: "GET https://api.ironflow.sh/v1/liquidations/stream (no daily limit for keyless streams)"
            allowed_from:
              type: integer
              format: int64
              description: On `HISTORY_OUT_OF_RANGE`, the earliest `from` (Unix ms) the caller's history reaches; a request from there is served.
              example: 1794394800000
            did_you_mean:
              type: array
              items: { type: string }
              description: On an unknown market (the liquidation map, the order book), the markets the name most likely meant. The message repeats them.
              example: ["xyz:GOLD-PERP", "PAXG-PERP"]

    Ironbox:
      type: object
      required: [id, name, state, size, mode, runtime, markets, region, created_at, url, dashboard, message]
      properties:
        id: { type: string, pattern: '^ibx_[0-9a-f]{12}$', example: ibx_3f9a0c7e12d4 }
        name: { type: string, description: "Empty when none was given.", example: grid-bot }
        state: { type: string, enum: [creating, running, stopped, deleted, error] }
        size: { type: string, enum: [small, medium, large] }
        mode: { type: string, enum: [paper], description: Ironbox is paper only. }
        runtime: { type: string, enum: [python, node] }
        markets:
          type: array
          items: { type: string, enum: [perps, outcomes, spot] }
        region: { type: string, enum: [tokyo, paper] }
        created_at: { type: integer, format: int64, description: Unix milliseconds }
        url: { type: string, format: uri, description: "GET it for the box's state; DELETE it to delete the box." }
        dashboard: { type: string, format: uri }
        message: { type: string, description: What the state means, in one line. }
        paper: { type: boolean, description: "True for a paper box: your own simulated Hyperliquid account." }
        trial: { type: boolean, description: "True on the answer that creates a keyless trial box." }
        expires_at: { type: integer, format: int64, description: "When a trial box ends at the latest, Unix milliseconds. It ends sooner after 2 hours with no requests and no strategy running. Only on a keyless trial box." }
        strategies_url: { type: string, format: uri, description: "Where to start strategies on this box. Shown only with base_url." }
        activity_url: { type: string, format: uri, description: "Where to watch this box. Shown only with base_url." }
        base_url:
          type: string
          format: uri
          description: "Set `base_url` in the official Hyperliquid SDK to this. Shown only when the box is created or rotated. Treat it as a low-value secret."
          example: https://api.ironflow.sh/box/ibxs_1f2e3d4c5b6a7890123456789abcdef0123456789abcdef0123456789abcdef0
        snippet: { type: string, description: "A first Python program for this box. Shown only when the box is created or rotated." }
        starting_balance_usd: { type: number, description: "Starting balance of a new paper box." }
        stats:
          type: object
          description: "A paper box's account, on GET. `as_of` is `live` or, if the engine cannot be reached, `snapshot`."
          required: [equity_usd, pnl_usd, fees_usd, funding_usd, positions, fill_count, data_gap_seconds, liquidated, as_of]
          properties:
            equity_usd: { type: number }
            pnl_usd: { type: number }
            fees_usd: { type: number }
            funding_usd: { type: number, description: "Net funding, negative when paid." }
            positions:
              type: array
              items:
                type: object
                properties:
                  coin: { type: string }
                  szi: { type: string }
                  entry_px: { type: string }
                  unrealized_pnl: { type: string }
                  leverage: { type: integer }
            fill_count: { type: integer }
            data_gap_seconds: { type: integer, description: "Seconds the box held something on a market with no live data." }
            liquidated: { type: boolean }
            as_of: { type: string, enum: [live, snapshot] }
      example:
        id: ibx_3f9a0c7e12d4
        name: grid-bot
        state: creating
        size: small
        mode: paper
        runtime: python
        markets: [perps, outcomes]
        region: tokyo
        created_at: 1762520000000
        url: https://api.ironflow.sh/v1/ironbox/ibx_3f9a0c7e12d4
        dashboard: https://ironflow.sh/ironbox
        message: Your Ironbox is being set up. We email you when it is ready.

    BoxDataWindow:
      type: object
      required: [from, to, history_days, max_days_per_request, requests_per_hour, datasets]
      properties:
        from: { type: string, format: date, description: First day this caller can read. }
        to: { type: string, format: date, description: "Last day: yesterday, UTC." }
        history_days: { type: [integer, "null"], description: "Days the window holds; null when the plan reaches everything stored." }
        max_days_per_request: { type: integer }
        requests_per_hour: { type: integer }
        datasets:
          type: array
          items:
            type: object
            required: [name, description, columns]
            properties:
              name: { type: string }
              description: { type: string }
              columns:
                type: array
                items:
                  type: object
                  required: [name, type]
                  properties:
                    name: { type: string }
                    type: { type: string, description: The column type as pyarrow reads it. }
        note: { type: string }

    BoxError:
      type: object
      description: "A box route's error, in the shape the Hyperliquid SDK reads. `error` is a stable word to branch on, when there is one."
      required: [code, msg]
      properties:
        code: { type: integer, example: 409 }
        msg: { type: string }
        error: { type: string, example: strategy_limit }

    StrategyParams:
      type: object
      required: [kind]
      description: "`market` and `max_loss_usd` are required except for `bot`, which takes the market from its file and defaults `max_loss_usd` to 10000."
      additionalProperties: false
      properties:
        kind: { type: string, enum: [breakout, grid, dca, bot] }
        market: { type: string, description: "A live main perp: BTC or BTC-PERP.", example: BTC }
        mode: { type: string, enum: [paper], description: "Anything else is refused." }
        max_loss_usd: { type: number, exclusiveMinimum: 0, maximum: 1000000, description: "Net loss (realized, unrealized at mark, fees, funding) that stops the strategy." }
        leverage: { type: integer, minimum: 1, description: "Cross leverage on this market. Default 3, or the market maximum if lower." }
        side: { type: string, enum: [long, short, both], description: "Breakout: long, short or both, default both. Grid and DCA: long or short, default long." }
        size_usd: { type: number, minimum: 10, maximum: 1000000, description: "Breakout: notional of each entry. Grid: total across the ladder, at least $10 a level." }
        lookback_bars: { type: integer, minimum: 5, maximum: 120, default: 20, description: "Breakout: entry channel." }
        exit_bars: { type: integer, minimum: 2, description: "Breakout: exit channel. Default half of lookback_bars." }
        bar_seconds: { type: integer, enum: [60, 300, 900], default: 60, description: "Breakout: bar length." }
        lower_px: { type: string, description: "Grid: bottom of the ladder.", example: "2900" }
        upper_px: { type: string, description: "Grid: top of the ladder.", example: "3100" }
        grids: { type: integer, minimum: 2, maximum: 25, default: 10, description: "Grid: rungs." }
        tranche_usd: { type: number, minimum: 10, maximum: 1000000, description: "DCA: notional of each entry." }
        budget_usd: { type: number, minimum: 10, maximum: 1000000, description: "DCA: total notional across the cycle." }
        dip_pct: { type: number, minimum: 0, maximum: 95, description: "DCA: move against the last entry that buys the next tranche. Default 3 unless interval_minutes is set." }
        interval_minutes: { type: integer, minimum: 0, maximum: 10080, description: "DCA: also buy a tranche after this long." }
        take_profit_pct: { type: number, minimum: 0, maximum: 1000, description: "DCA: close the stack at this gain on the average entry; the strategy stops with take_profit, or starts again with repeat." }
        repeat: { type: boolean, description: "DCA: start a new cycle after a take profit." }
        bot: { type: object, description: "Bot: the bot.yaml as JSON. Validated field by field as the schema says; unknown fields are refused." }
        warmup:
          type: object
          description: "Bot, start only: recent history, so the rule can judge at once. Hourly rows carry the hour's start in ms; only closed hours. Never stored or returned."
          additionalProperties: false
          properties:
            candles: { type: array, maxItems: 400, items: { type: array, minItems: 6, maxItems: 6, items: { type: number } }, description: "[start, open, high, low, close, volume]" }
            liquidations: { type: array, maxItems: 20000, items: { type: array, minItems: 4, maxItems: 4, items: { type: [number, string] } }, description: "[ms, long or short (the position closed), usd, price]" }
            liquidations_from: { type: integer, format: int64, description: "Where the liquidation history starts. Required with liquidations." }
            open_interest: { type: array, maxItems: 400, items: { type: array, minItems: 2, maxItems: 2, items: { type: number } }, description: "[start, open interest in coin units]" }
            flow: { type: array, maxItems: 400, items: { type: array, minItems: 4, maxItems: 4, items: { type: number } }, description: "[start, volume usd, taker buys usd, taker sells usd]" }
            funding: { type: array, maxItems: 50, items: { type: array, minItems: 2, maxItems: 2, items: { type: number } }, description: "[ms, rate as a fraction per hour]" }
            flow_hour: { type: array, minItems: 3, maxItems: 3, items: { type: number }, description: "[this hour's start, volume usd so far, taker buys usd so far]" }

    Strategy:
      type: object
      required: [id, kind, market, status, stop_reason, stop_detail, params, started_at, stopped_at, stats]
      properties:
        id: { type: string, example: strat_9f2c4a7e1b3d5c60 }
        kind: { type: string, enum: [breakout, grid, dca, bot] }
        market: { type: string }
        status: { type: string, enum: [running, stopping, stopped] }
        stop_reason: { type: [string, 'null'], enum: [max_loss, stopped_by_user, liquidated, order_rejected, take_profit, box_reset, box_deleted, expired, null], description: "Why it stopped, set as soon as it starts stopping. `expired`: a bot reached the day in its expires field." }
        stop_detail: { type: [string, 'null'], description: "The reason in words, with the numbers." }
        params: { $ref: '#/components/schemas/StrategyParams' }
        started_at: { type: integer, format: int64, description: Unix milliseconds }
        stopped_at: { type: [integer, 'null'], format: int64, description: Unix milliseconds }
        stats:
          type: object
          required: [pnl_usd, realized_pnl_usd, unrealized_pnl_usd, fees_usd, funding_usd, max_loss_usd, position, entry_px, orders, fills, last_decision]
          properties:
            pnl_usd: { type: number, description: "Net result: realized plus unrealized plus funding minus fees." }
            realized_pnl_usd: { type: number }
            unrealized_pnl_usd: { type: number }
            fees_usd: { type: number }
            funding_usd: { type: number }
            max_loss_usd: { type: number }
            position: { type: string, description: "This strategy's share of the box's position, signed. Once stopped, what it left open." }
            entry_px: { type: string, description: "Empty when flat." }
            orders: { type: integer }
            fills: { type: integer }
            last_decision: { type: string }
            grid_levels: { type: integer, description: Grid only. }
            grid_cycles: { type: integer, description: "Grid only: completed buy and sell pairs." }
            dca_spent_usd: { type: number, description: DCA only. }
            bars: { type: integer, description: Breakout only. }
            signals: { type: integer, description: "Bot only: times the rule turned true and asked for an entry." }
            warmup_events: { type: integer, description: "Bot only: history events it started with." }
        watch_url: { type: string, format: uri, description: "Read-only page of this strategy (GET /box/watch/{id} on the API). Safe to share: it cannot trade or stop anything.", example: "https://ironflow.sh/run/w_3f9a0c7e12d4b5a6c7d8e9f0a1b2c3d4" }

    StrategyAnswer:
      type: object
      required: [strategy, paper]
      properties:
        strategy: { $ref: '#/components/schemas/Strategy' }
        paper: { type: boolean, enum: [true] }

    StrategyList:
      type: object
      required: [strategies, running, max_running, paper]
      properties:
        strategies:
          type: array
          items: { $ref: '#/components/schemas/Strategy' }
        running: { type: integer }
        max_running: { type: integer, example: 3 }
        paper: { type: boolean, enum: [true] }

    BoxActivity:
      type: object
      required: [events, next_cursor, has_more, gap, account, running_strategies, paper]
      properties:
        events:
          type: array
          items:
            type: object
            required: [seq, time, kind]
            properties:
              seq: { type: integer, format: int64 }
              time: { type: integer, format: int64, description: Unix milliseconds }
              kind: { type: string, enum: [strategy_started, decision, order, fill, funding, strategy_stopped, liquidated] }
              strategy_id: { type: string, description: "Set on what a strategy did. Absent on your own signed orders." }
              data: { type: object, description: "Depends on kind. decision: action, reason_code, reason. order: coin, side, px, sz, tif, reduce_only, status, oid, error. fill: tid, oid, coin, side, px, sz, fee_usd, closed_pnl_usd, dir, crossed, liquidation. funding: coin, szi, rate, usd. strategy_stopped: kind, market, reason, detail, pnl_usd, realized_pnl_usd, fees_usd, position." }
        next_cursor: { type: integer, format: int64, description: Pass as after next time. }
        has_more: { type: boolean }
        gap: { type: boolean, description: "True when events between your cursor and the first returned are gone." }
        account:
          type: object
          required: [equity_usd, start_usd, pnl_usd, fees_usd, funding_usd, max_drawdown_pct, data_gap_seconds, liquidated, open_orders, positions]
          properties:
            equity_usd: { type: number }
            start_usd: { type: number }
            pnl_usd: { type: number }
            fees_usd: { type: number }
            funding_usd: { type: number }
            max_drawdown_pct: { type: number }
            data_gap_seconds: { type: integer }
            liquidated: { type: boolean }
            open_orders: { type: integer }
            positions:
              type: array
              items:
                type: object
                properties:
                  coin: { type: string }
                  szi: { type: string }
                  entry_px: { type: string }
                  unrealized_pnl: { type: string }
                  leverage: { type: integer }
        running_strategies: { type: integer }
        expires_at: { type: integer, format: int64, description: "When a trial box ends at the latest, Unix milliseconds. Only on a trial box." }
        paper: { type: boolean, enum: [true] }

    PromoteReport:
      type: object
      required: [id, mode, promoted, live_orders_on_ironflow, message]
      properties:
        id: { type: string }
        mode: { type: string, enum: [paper] }
        promoted: { type: boolean, enum: [false] }
        live_orders_on_ironflow: { type: boolean, enum: [false] }
        message: { type: string }
        go_live_on_hyperliquid:
          type: object
          description: Kept for older clients. Ironbox is paper only.
          properties:
            base_url: { type: string, format: uri }
            wallet: { type: string }
        paper_report:
          type: object
          properties:
            days_active: { type: integer }
            orders: { type: integer }
            fills: { type: integer }
            fees_usd: { type: number }
            funding_usd: { type: number }
            equity_usd: { type: number }
            max_drawdown_pct: { type: number }
            rejected_orders: { type: integer }
            data_gap_seconds: { type: integer }
        keep_box: { type: string, description: "Only on a keyless trial box: what a free key gives." }

  # ─── Reusable Responses ──────────────────────────────────────────────────

  responses:
    BoxNotFound:
      description: Unknown box URL, or no such strategy on this box (`strategy_not_found`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/BoxError'
          example:
            code: 404
            msg: unknown paper box

    BadRequest:
      description: Invalid request parameters.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: BAD_REQUEST
              message: "market is required (e.g. BTC-PERP)"

    LiquidationMapUnavailable:
      description: The map is not ready (its service is starting) or its service did not answer and no recent map is cached. Retry after the Retry-After seconds.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer, example: 30 }
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: UPSTREAM_UNAVAILABLE
              message: "the liquidation map is not available right now (it is being built or its service did not answer); retry in 30 seconds"

    QueryWindowExceeded:
      description: Time range exceeds the per-request maximum for the caller's tier. Narrow the from/to window and paginate.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: QUERY_WINDOW_EXCEEDED
              message: "query time range exceeds maximum 24h0m0s per request for your tier — narrow your from/to window and paginate"

    PerpOnlyBadRequest:
      description: |
        Bad request. Two distinct codes share this status on perp-only endpoints:
        - `QUERY_WINDOW_EXCEEDED` — `to - from` exceeds the per-tier max window. Narrow and paginate.
        - `INVALID_MARKET_CLASS` — `market` or `base_market` is a spot symbol (e.g. `HYPE/USDC`, `@285`); this endpoint serves perpetuals only.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            queryWindow:
              summary: Query window exceeded
              value:
                error:
                  code: QUERY_WINDOW_EXCEEDED
                  message: "query time range exceeds maximum 24h0m0s per request for your tier — narrow your from/to window and paginate"
            invalidMarketClass:
              summary: Spot symbol on perp-only endpoint
              value:
                error:
                  code: INVALID_MARKET_CLASS
                  message: "this endpoint serves perpetual markets only; spot symbols (e.g. \"HYPE/USDC\", \"@285\") are not supported"

    Unauthorized:
      description: Missing or invalid API key.
      headers:
        WWW-Authenticate:
          schema:
            type: string
            example: Bearer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: UNAUTHORIZED
              message: "Invalid or missing API key"

    BoxDataUnauthorized:
      description: "`BOX_TOKEN_REQUIRED` (no box secret and no key) or `INVALID_BOX_TOKEN` (the box was deleted, rotated or is past its 24 hours)."
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: INVALID_BOX_TOKEN
              message: "this box secret names no live paper box (deleted, rotated or past its 24 hours)"

    IronboxKeyRequired:
      description: No API key. Any key works, a free one included.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: UNAUTHORIZED
              message: 'this request needs an API key. Free keys: https://ironflow.sh/key?utm_source=api, or POST https://api.ironflow.sh/v1/keys with {"email": "you@example.com"} (the key is emailed to that address, never returned, so a person has to open the email)'

    Forbidden:
      description: Your tier does not include this feature.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: TIER_FORBIDDEN
              message: "Export requires Builder tier or above"

    RateLimited:
      description: Rate limit exceeded.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
            example: 60
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: RATE_LIMIT_EXCEEDED
              message: "Rate limit exceeded. Retry after 60 seconds."

    QueryTimeout:
      description: Query exceeded the 15-second execution limit. Narrow your time range, add a market filter, or reduce the limit.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: QUERY_TIMEOUT
              message: "query took too long — narrow your time range, add a market filter, or reduce the limit"

    InternalError:
      description: Internal server error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: INTERNAL_ERROR
              message: "An unexpected error occurred"
