openapi: 3.0.3

info:
  title: CEX Listings Monitor API
  version: '1.1.0'
  description: |
    The CEX Listings Monitor REST API provides programmatic access to real-time exchange
    listing data, announcements with LLM sentiment scores, live cross-exchange prices,
    watchlist management, alert rules, and simulation history.

    ## Authentication

    All endpoints except `/api/health` require a Bearer token:

    ```
    Authorization: Bearer cex_<your_key>
    ```

    Generate keys from the bot with `/apikey generate [label]`.
    Keys are SHA-256 hashed at rest and shown **exactly once** after creation.

    ## Rate Limits

    Rate limits are enforced per key using a **60-second fixed window**:

    | Tier          | Requests / minute |
    |---------------|-------------------|
    | basic         | 60                |
    | pro           | 1,000             |

    When exceeded the API returns `HTTP 429` with `retryAfterMs` in the body.

    ## Tier Enforcement

    Some endpoints require a minimum subscription tier.
    A `403` response is returned when your key tier is insufficient.

    ## Platform-scoped endpoints

    Watchlist, alert rules, and API key endpoints are scoped to the platform identity
    (Telegram user or Discord server) that the API key belongs to. Standalone keys
    cannot use these endpoints.

servers:
  - url: https://cex.holits.com/api
    description: Production

tags:
  - name: Health
    description: Service status — no authentication required
  - name: Pairs
    description: Newly detected trading pairs (Basic+)
  - name: Announcements
    description: Exchange announcements with sentiment (Basic+)
  - name: Prices
    description: Live cross-exchange prices (Pro+)
  - name: Exchanges
    description: Exchange listing data for an asset (Basic+)
  - name: Watchlist
    description: Manage your watchlist and price alerts (Basic+)
  - name: Alert Rules
    description: Configure new-listing alert filters (Basic+)
  - name: Simulations
    description: Listing simulation history (Pro+)
  - name: API Keys
    description: Manage your API keys (Basic+)

paths:
  /health:
    get:
      tags: [Health]
      summary: Service health check
      description: Returns the current health status and API version. No authentication required. Use for uptime monitoring.
      security: []
      responses:
        '200':
          description: Service is healthy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
              example:
                status: ok
                version: '1.0.0'
                timestamp: '2024-01-15T12:00:00.000Z'

  /pairs/new:
    get:
      tags: [Pairs]
      summary: List newly detected trading pairs
      description: |
        Returns trading pairs that have been newly detected across monitored exchanges.
        Results are sorted by listing time descending.

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      parameters:
        - name: since
          in: query
          description: ISO 8601 timestamp. Only pairs detected after this time are returned. Defaults to 24 hours ago.
          required: false
          schema:
            type: string
            format: date-time
          example: '2024-01-14T00:00:00.000Z'
        - name: exchange
          in: query
          description: Filter by exchange name (lowercase).
          required: false
          schema:
            type: string
            enum: [binance, okx, bybit, mexc, bitget, coinbase, bingx, bitfinex, deribit]
          example: binance
        - name: limit
          in: query
          description: Maximum number of results to return. Capped at 200.
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: offset
          in: query
          description: Number of results to skip for pagination.
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: List of new trading pairs
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PairsResponse'
              example:
                data:
                  - symbol: XYZUSDT
                    baseAsset: XYZ
                    quoteAsset: USDT
                    exchange: binance
                    tradingMode: spot
                    listedAt: '2024-01-15T10:30:00.000Z'
                total: 1
                since: '2024-01-14T10:30:00.000Z'
                limit: 50
                offset: 0
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

  /announcements:
    get:
      tags: [Announcements]
      summary: List exchange announcements
      description: |
        Returns announcements scraped from official exchange announcement pages.
        Each announcement includes extracted ticker symbols and an LLM-assigned
        sentiment score (`positive`, `neutral`, or `negative`).

        Results are sorted by announcement date descending.

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      parameters:
        - name: exchange
          in: query
          description: Filter by exchange name (lowercase).
          required: false
          schema:
            type: string
            enum: [binance, okx, bybit, mexc, bitget, coinbase, bingx, bitfinex, deribit]
          example: binance
        - name: since
          in: query
          description: ISO 8601 timestamp. Only announcements published after this time are returned.
          required: false
          schema:
            type: string
            format: date-time
          example: '2024-01-14T00:00:00.000Z'
        - name: limit
          in: query
          description: Maximum number of results to return. Capped at 200.
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: offset
          in: query
          description: Number of results to skip for pagination.
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: List of announcements
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnnouncementsResponse'
              example:
                data:
                  - title: 'Binance Will List XYZ Token (XYZ)'
                    link: 'https://www.binance.com/en/support/announcement/abc123'
                    exchange: binance
                    date: '2024-01-15T09:00:00.000Z'
                    tickers: [XYZ]
                    sentiment: positive
                total: 1
                limit: 50
                offset: 0
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

  /prices/{symbol}:
    get:
      tags: [Prices]
      summary: Live cross-exchange prices
      description: |
        Returns the current price of an asset from all monitored exchanges simultaneously,
        along with the min/max spread across exchanges.

        Exchanges that do not list the asset or encounter an error return `null` for that exchange.

        You can pass the symbol with or without the quote asset suffix:
        - `BTC` → treated as `BTC/USDT`
        - `BTCUSDT` → automatically strips `USDT`
        - `BTCUSDC` → automatically strips `USDC`

        **Minimum tier:** Pro
      security:
        - BearerAuth: []
      parameters:
        - name: symbol
          in: path
          description: Asset symbol (e.g. `BTC`, `ETH`, `BTCUSDT`).
          required: true
          schema:
            type: string
          example: BTC
      responses:
        '200':
          description: Live prices from all exchanges
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PricesResponse'
              example:
                symbol: BTC
                quote: USDT
                prices:
                  binance:
                    price: 50000.5
                    updatedAt: '2024-01-15T12:00:00.000Z'
                  okx:
                    price: 50050.0
                    updatedAt: '2024-01-15T12:00:00.000Z'
                  mexc: null
                  bybit:
                    price: 49990.0
                    updatedAt: '2024-01-15T12:00:00.000Z'
                spread:
                  min: 49990.0
                  max: 50050.0
                  spreadPct: 0.12
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

  /exchanges/{symbol}:
    get:
      tags: [Exchanges]
      summary: Exchange listing data for an asset
      description: |
        Returns which exchanges list the given asset, along with available trading modes
        and quote assets.

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      parameters:
        - name: symbol
          in: path
          description: Base asset symbol (e.g. `BTC`, `ETH`, `XYZ`).
          required: true
          schema:
            type: string
          example: BTC
      responses:
        '200':
          description: Exchange listing data for the asset
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExchangeListingResponse'
              example:
                symbol: BTC
                exchanges: [binance, okx, bybit, mexc, bitget, coinbase, bingx, bitfinex, deribit]
                tradingModes:
                  spot: true
                  futures: true
                quoteAssets: [USDT, USDC, BTC]
                firstListed: '2020-01-01T00:00:00.000Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

  /watchlist:
    get:
      tags: [Watchlist]
      summary: Get watchlist
      description: |
        Returns all assets on the watchlist for the platform identity linked to the API key.
        Only available for keys linked to a Telegram user or Discord server.

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Watchlist items
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WatchlistResponse'
              example:
                data:
                  - asset: BTC
                    alertPrice: 55000
                    alertDirection: above
                    alertTriggered: false
                    addedAt: '2024-01-15T10:00:00.000Z'
                total: 1
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/StandaloneKeyNotSupported'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

    post:
      tags: [Watchlist]
      summary: Add asset to watchlist
      description: |
        Adds an asset to the watchlist. If the asset is already on the watchlist
        the alert settings are updated (upsert). Optionally set a price alert
        (`alertPrice` + `alertDirection`).

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WatchlistAddRequest'
            example:
              asset: BTC
              alertPrice: 55000
              alertDirection: above
      responses:
        '200':
          description: Watchlist item created or updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WatchlistItem'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/StandaloneKeyNotSupported'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

  /watchlist/{asset}:
    delete:
      tags: [Watchlist]
      summary: Remove asset from watchlist
      description: |
        Removes an asset from the watchlist.

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      parameters:
        - name: asset
          in: path
          description: Asset symbol to remove (case-insensitive).
          required: true
          schema:
            type: string
          example: BTC
      responses:
        '200':
          description: Asset removed from watchlist
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: string
                    example: BTC
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/StandaloneKeyNotSupported'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

  /alertrules:
    get:
      tags: [Alert Rules]
      summary: Get alert rules
      description: |
        Returns the current new-listing alert filter rules for the platform identity
        linked to this API key. Returns defaults when no rules have been configured.

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Current alert rules
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AlertRule'
              example:
                minExchanges: 2
                tradingModes: [spot]
                exchanges: []
                excludeExchanges: [bingx]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/StandaloneKeyNotSupported'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

    put:
      tags: [Alert Rules]
      summary: Update alert rules
      description: |
        Updates one or more alert rule fields. Only provided fields are changed.
        Send an empty array `[]` to clear a list field back to its default (all).

        | Field | Default | Effect |
        |---|---|---|
        | `minExchanges` | 1 | Minimum exchanges an asset must be listed on to trigger an alert |
        | `tradingModes` | [] (all) | Restrict alerts to specific trading modes |
        | `exchanges` | [] (all) | Whitelist — only alert for these exchanges |
        | `excludeExchanges` | [] | Blacklist — never alert for these exchanges |

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AlertRuleUpdateRequest'
            example:
              minExchanges: 2
              tradingModes: [spot]
              excludeExchanges: [bingx]
      responses:
        '200':
          description: Updated alert rules
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AlertRule'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/StandaloneKeyNotSupported'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

    delete:
      tags: [Alert Rules]
      summary: Clear alert rules
      description: |
        Resets all alert rules to their defaults (no filters — all new listings trigger alerts).

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Rules cleared — returns default values
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AlertRule'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/StandaloneKeyNotSupported'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

  /simulations:
    get:
      tags: [Simulations]
      summary: List simulation history
      description: |
        Returns the history of price simulations that were automatically triggered
        when new exchange listings were detected. Each simulation tracks a mock
        $100 buy at the detected listing price and monitors P&L until the asset
        appears on a second exchange.

        Results are sorted by start time descending.

        **Minimum tier:** Pro
      security:
        - BearerAuth: []
      parameters:
        - name: status
          in: query
          description: Filter by simulation status.
          required: false
          schema:
            type: string
            enum: [active, completed]
        - name: limit
          in: query
          description: Maximum number of results to return. Capped at 200.
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: offset
          in: query
          description: Number of results to skip for pagination.
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Simulation history
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimulationsResponse'
              example:
                data:
                  - id: '65a1b2c3d4e5f6789abc0001'
                    asset: XYZ
                    targetExchange: binance
                    sourceExchange: okx
                    newExchange: binance
                    buyPrice: 0.052
                    mockBuyValue: 100
                    status: completed
                    finalProfitLoss: 12.4
                    startedAt: '2024-01-15T08:00:00.000Z'
                    updatedAt: '2024-01-15T10:30:00.000Z'
                total: 1
                limit: 50
                offset: 0
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

  /apikeys:
    get:
      tags: [API Keys]
      summary: List API keys
      description: |
        Returns all API keys for the platform identity linked to this key.
        The raw key value is never returned — only the prefix, tier, label, and usage stats.

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      responses:
        '200':
          description: List of API keys
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeysResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/StandaloneKeyNotSupported'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

    post:
      tags: [API Keys]
      summary: Generate a new API key
      description: |
        Generates a new API key for the platform identity linked to this key.
        Maximum 3 keys per account. The key is shown **exactly once** — store it securely.

        The new key's tier matches your current subscription tier.

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  description: Optional human-readable label for this key.
                  example: production
      responses:
        '201':
          description: New API key generated
          content:
            application/json:
              schema:
                type: object
                required: [key, note]
                properties:
                  key:
                    type: string
                    description: The plaintext API key. Store this securely — it will not be shown again.
                    example: 'cex_abcdef1234567890abcdef1234567890'
                  note:
                    type: string
                    example: 'Store this key securely — it will not be shown again.'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/StandaloneKeyNotSupported'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

  /apikeys/{prefix}:
    delete:
      tags: [API Keys]
      summary: Revoke an API key
      description: |
        Revokes an API key by its prefix. The key is identified by its 8-character prefix
        (e.g. `cex_abcd`). You can only revoke keys belonging to your own platform identity.

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      parameters:
        - name: prefix
          in: path
          description: 8-character key prefix (the first 8 characters of the key, e.g. `cex_abcd`).
          required: true
          schema:
            type: string
          example: cex_abcd
      responses:
        '200':
          description: Key revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: string
                    example: cex_abcd
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/StandaloneKeyNotSupported'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

  /portfolio:
    get:
      tags: [Portfolio]
      summary: Get paper trading portfolio
      description: |
        Returns all simulated positions with live P/L calculations. This is **paper trading only** — no real trades are executed.

        Each position shows:
        - Asset symbol and quantity
        - Entry price and current market price
        - Unrealized P/L (dollar amount and percentage)
        - Total value and cost basis

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Portfolio retrieved
          content:
            application/json:
              schema:
                type: object
                properties:
                  totalValue:
                    type: number
                    description: Total value of all positions in USD
                  totalCost:
                    type: number
                    description: Total cost basis in USD
                  totalUnrealizedPnL:
                    type: number
                    description: Total unrealized profit/loss in USD
                  totalUnrealizedPnLPercent:
                    type: number
                    description: Total unrealized P/L as percentage
                  totalRealizedPnL:
                    type: number
                    description: Total realized profit/loss from closed positions
                  positions:
                    type: array
                    items:
                      type: object
                      properties:
                        asset:
                          type: string
                        quantity:
                          type: number
                        entryPrice:
                          type: number
                        currentPrice:
                          type: number
                        entryValue:
                          type: number
                        currentValue:
                          type: number
                        unrealizedPnL:
                          type: number
                        unrealizedPnLPercent:
                          type: number
                        realizedPnL:
                          type: number
                        openedAt:
                          type: string
                          format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

    post:
      tags: [Portfolio]
      summary: Add paper trading position
      description: |
        Add a simulated position to your paper trading portfolio. **No real trades are executed.**

        The position tracks:
        - Asset symbol (e.g., BTC, ETH)
        - Quantity held (simulated)
        - Entry price (your simulated purchase price in USD)

        Uses upsert logic — if a position for the asset already exists, it will be updated.

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [asset, quantity, entryPrice]
              properties:
                asset:
                  type: string
                  description: Asset symbol (e.g., BTC, ETH)
                  example: BTC
                quantity:
                  type: number
                  description: Quantity of the asset (simulated)
                  example: 0.5
                entryPrice:
                  type: number
                  description: Simulated entry price in USD
                  example: 50000
      responses:
        '201':
          description: Position added
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PortfolioPosition'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

  /portfolio/{asset}:
    delete:
      tags: [Portfolio]
      summary: Close paper trading position
      description: |
        Close a simulated position from your paper trading portfolio. **No real trades are executed.**

        This calculates and returns the realized P/L based on the current market price versus your entry price.

        **Minimum tier:** Basic
      security:
        - BearerAuth: []
      parameters:
        - name: asset
          in: path
          description: Asset symbol to close (e.g., BTC, ETH)
          required: true
          schema:
            type: string
          example: BTC
      responses:
        '200':
          description: Position closed
          content:
            application/json:
              schema:
                type: object
                properties:
                  realizedPnL:
                    type: number
                    description: Simulated realized profit/loss in USD
                  entryValue:
                    type: number
                  exitValue:
                    type: number
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/ServerError'

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        API key generated via `/apikey generate` in the Telegram or Discord bot.
        Format: `cex_<32 hex chars>`. Keys are hashed at rest and shown once only.

  schemas:
    HealthResponse:
      type: object
      required: [status, version, timestamp]
      properties:
        status:
          type: string
          enum: [ok]
        version:
          type: string
          example: '1.0.0'
        timestamp:
          type: string
          format: date-time

    TradingPair:
      type: object
      required: [symbol, baseAsset, quoteAsset, exchange, tradingMode, listedAt]
      properties:
        symbol:
          type: string
          example: XYZUSDT
          description: Full trading pair symbol as it appears on the exchange.
        baseAsset:
          type: string
          example: XYZ
          description: The asset being traded.
        quoteAsset:
          type: string
          example: USDT
          description: The settlement currency.
        exchange:
          type: string
          enum: [binance, okx, bybit, mexc, bitget, coinbase, bingx, bitfinex, deribit]
          example: binance
        tradingMode:
          type: string
          enum: [spot, futures, both]
          example: spot
        listedAt:
          type: string
          format: date-time
          description: Timestamp when the pair was first detected.

    PairsResponse:
      type: object
      required: [data, total, since, limit, offset]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/TradingPair'
        total:
          type: integer
          description: Total number of pairs matching the query (before pagination).
        since:
          type: string
          format: date-time
          description: The lower-bound timestamp applied to this query.
        limit:
          type: integer
        offset:
          type: integer

    Announcement:
      type: object
      required: [title, link, exchange, date, tickers]
      properties:
        title:
          type: string
          example: 'Binance Will List XYZ Token (XYZ)'
        link:
          type: string
          format: uri
          description: URL of the original announcement page.
        exchange:
          type: string
          enum: [binance, okx, bybit, mexc, bitget, coinbase, bingx, bitfinex, deribit]
        date:
          type: string
          format: date-time
          description: Date/time the announcement was published.
        tickers:
          type: array
          items:
            type: string
          description: Asset ticker symbols extracted from the announcement title.
          example: [XYZ]
        sentiment:
          type: string
          nullable: true
          enum: [positive, neutral, negative]
          description: LLM-assigned sentiment for the announcement title. Null if classification has not run yet.

    AnnouncementsResponse:
      type: object
      required: [data, total, limit, offset]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Announcement'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    ExchangePrice:
      type: object
      nullable: true
      properties:
        price:
          type: number
          format: float
          example: 50000.5
        updatedAt:
          type: string
          format: date-time

    Spread:
      type: object
      nullable: true
      properties:
        min:
          type: number
          format: float
          example: 49990.0
        max:
          type: number
          format: float
          example: 50050.0
        spreadPct:
          type: number
          format: float
          description: Percentage difference between min and max price.
          example: 0.12

    PricesResponse:
      type: object
      required: [symbol, quote, prices]
      properties:
        symbol:
          type: string
          example: BTC
          description: Base asset (with quote suffix stripped).
        quote:
          type: string
          example: USDT
        prices:
          type: object
          description: Map of exchange name → price data. Null when exchange doesn't list the asset or returned an error.
          additionalProperties:
            $ref: '#/components/schemas/ExchangePrice'
        spread:
          $ref: '#/components/schemas/Spread'

    ExchangeListingResponse:
      type: object
      required: [symbol, exchanges, tradingModes, quoteAssets]
      properties:
        symbol:
          type: string
          example: BTC
        exchanges:
          type: array
          items:
            type: string
          description: List of exchanges that list this asset.
          example: [binance, okx, bybit]
        tradingModes:
          type: object
          properties:
            spot:
              type: boolean
            futures:
              type: boolean
        quoteAssets:
          type: array
          items:
            type: string
          description: Quote assets this asset trades against.
          example: [USDT, USDC]
        firstListed:
          type: string
          format: date-time
          description: Timestamp when the asset was first detected on any exchange.

    WatchlistItem:
      type: object
      required: [asset, alertTriggered, addedAt]
      properties:
        asset:
          type: string
          example: BTC
        alertPrice:
          type: number
          nullable: true
          example: 55000
        alertDirection:
          type: string
          nullable: true
          enum: [above, below]
          example: above
        alertTriggered:
          type: boolean
          description: True if the price alert has already fired.
        addedAt:
          type: string
          format: date-time

    WatchlistResponse:
      type: object
      required: [data, total]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/WatchlistItem'
        total:
          type: integer

    WatchlistAddRequest:
      type: object
      required: [asset]
      properties:
        asset:
          type: string
          description: Asset symbol to watch (case-insensitive).
          example: BTC
        alertPrice:
          type: number
          description: Price at which to send an alert. Must be paired with alertDirection.
          example: 55000
        alertDirection:
          type: string
          enum: [above, below]
          description: Whether to alert when price goes above or below alertPrice.
          example: above

    AlertRule:
      type: object
      required: [minExchanges, tradingModes, exchanges, excludeExchanges]
      properties:
        minExchanges:
          type: integer
          minimum: 1
          description: Minimum number of exchanges that must list the asset to trigger an alert.
          example: 1
        tradingModes:
          type: array
          items:
            type: string
            enum: [spot, futures]
          description: Only alert for these trading modes. Empty array means all modes.
          example: []
        exchanges:
          type: array
          items:
            type: string
          description: Only alert for listings on these exchanges (whitelist). Empty array means all exchanges.
          example: []
        excludeExchanges:
          type: array
          items:
            type: string
          description: Never alert for listings on these exchanges (blacklist).
          example: []

    AlertRuleUpdateRequest:
      type: object
      properties:
        minExchanges:
          type: integer
          minimum: 1
          example: 2
        tradingModes:
          type: array
          items:
            type: string
            enum: [spot, futures]
          example: [spot]
        exchanges:
          type: array
          items:
            type: string
          example: []
        excludeExchanges:
          type: array
          items:
            type: string
          example: [bingx]

    SimulationSummary:
      type: object
      required:
        [
          id,
          asset,
          targetExchange,
          sourceExchange,
          newExchange,
          buyPrice,
          mockBuyValue,
          status,
          startedAt,
          updatedAt,
        ]
      properties:
        id:
          type: string
          example: abc123
        asset:
          type: string
          example: BTC
        targetExchange:
          type: string
          example: binance
        sourceExchange:
          type: string
          example: okx
        newExchange:
          type: string
          example: bybit
        buyPrice:
          type: number
        mockBuyValue:
          type: number
        status:
          type: string
          enum: [active, completed]
        finalProfitLoss:
          type: number
          nullable: true
        startedAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    PortfolioPosition:
      type: object
      properties:
        platformType:
          type: string
          enum: [telegram, discord]
        platformId:
          type: string
        asset:
          type: string
          example: BTC
        quantity:
          type: number
          example: 0.5
        entryPrice:
          type: number
          example: 50000
        openedAt:
          type: string
          format: date-time
        closedAt:
          type: string
          format: date-time
          nullable: true
        realizedPnL:
          type: number
          nullable: true

    SimulationsResponse:
      type: object
      required: [data, total, limit, offset]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/SimulationSummary'
        total:
          type: integer
        limit:
          type: integer
        offset:
          type: integer

    ApiKeySummary:
      type: object
      required: [prefix, tier, requestCount, createdAt]
      properties:
        prefix:
          type: string
          description: First 8 characters of the key (used to identify/revoke).
          example: cex_abcd
        tier:
          type: string
          enum: [basic, pro]
        label:
          type: string
          nullable: true
          description: Optional label assigned at creation.
          example: production
        requestCount:
          type: integer
          description: Total number of requests made with this key.
          example: 142
        lastUsedAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time

    ApiKeysResponse:
      type: object
      required: [data, total]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ApiKeySummary'
        total:
          type: integer

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string
          example: Invalid API key

    RateLimitErrorResponse:
      type: object
      required: [error, retryAfterMs]
      properties:
        error:
          type: string
          example: Rate limit exceeded
        retryAfterMs:
          type: integer
          description: Milliseconds until the current rate-limit window resets.
          example: 42000

  responses:
    Unauthorized:
      description: Missing, malformed, or invalid API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: Invalid API key

    Forbidden:
      description: Valid key but subscription tier is below the requirement for this endpoint.
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
          example:
            error: 'This endpoint requires pro tier or above. Your key is basic tier.'

    NotFound:
      description: The requested resource was not found.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: Asset not found

    BadRequest:
      description: Invalid request body or query parameters.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: 'alertDirection must be "above" or "below"'

    StandaloneKeyNotSupported:
      description: This endpoint requires a key linked to a Telegram user or Discord server, not a standalone key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: 'This endpoint is not available for standalone API keys. Use a key linked to a Telegram user or Discord server.'

    RateLimited:
      description: Rate limit exceeded for this API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/RateLimitErrorResponse'

    ServerError:
      description: Internal server error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error: Internal server error

security:
  - BearerAuth: []
