openapi: "3.1.0"
info:
  title: Scanna API
  version: 0.1.0
  description: |
    Prediction market intelligence API. Real-time signals, ML-powered mispricing detection,
    smart money tracking, and cross-venue arbitrage across Polymarket and Kalshi.

    ## Rate Limits

    A single per-key rate limit applies across ALL authenticated endpoints (the global cap):
    Free 10/min, Pro 60/min, Enterprise 300/min. `POST /heat` has an additional stricter
    per-route cap on top of the global limit. Exceeding the limit returns `429` with a
    `Retry-After` header.
  contact:
    name: Scanna
    url: https://x.com/scanna_app
    email: contact@scanna.xyz
  license:
    name: MIT

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

security:
  - ApiKeyAuth: []
  - BearerAuth: []

tags:
  - name: Signals
    description: Heat scores, trending markets, and composite signal analysis
  - name: Predictions
    description: ML-powered probability predictions and mispricing detection
  - name: Analytics
    description: Risk scoring, arbitrage detection, insider analysis, and wallet clustering
  - name: Data
    description: Smart money leaderboard, cross-venue divergence, and ML training data export
  - name: Admin
    description: Public health checks and monitoring (no API key required)

paths:
  # ─── Public ──────────────────────────────────────────────────────────

  /health:
    get:
      tags: [Admin]
      summary: Health check
      security: []
      operationId: getHealth
      responses:
        "200":
          description: Server status
          content:
            application/json:
              schema:
                type: object
                required: [status, markets_cached]
                properties:
                  status:
                    type: string
                    example: ok
                  markets_cached:
                    type: integer
                    example: 2100

  /ready:
    get:
      tags: [Admin]
      summary: Readiness check
      description: >-
        Returns 200 only when the database is connected and the market cache
        holds fresh data; 503 otherwise. Monitors and load balancers should
        target this endpoint (not /health) to decide whether to route traffic.
        /health is liveness-only and stays 200 while the process is up.
      security: []
      operationId: getReady
      responses:
        "200":
          description: Service ready to handle traffic
          content:
            application/json:
              schema:
                type: object
                required: [status, db_connected, markets_cached]
                properties:
                  status:
                    type: string
                    example: ok
                  db_connected:
                    type: boolean
                    example: true
                  markets_cached:
                    type: integer
                    example: 2100
        "503":
          description: Service not ready (database unreachable or cache empty/stale)
          content:
            application/json:
              schema:
                type: object
                required: [status, db_connected, markets_cached]
                properties:
                  status:
                    type: string
                    example: degraded
                  db_connected:
                    type: boolean
                    example: false
                  markets_cached:
                    type: integer
                    example: 0

  /deploy-ready:
    get:
      tags: [Admin]
      summary: Deployment traffic gate
      description: >-
        Returns 200 only after boot-time database initialization has resolved
        (migrations complete, or the database is intentionally absent → in-memory
        mode); 503 while still starting. Railway's deploy healthcheck targets
        this endpoint so a rolling deploy shifts traffic to a new instance only
        after its migrations have run — never mid-DDL. Unlike /ready it does not
        depend on a live database ping or market freshness.
      security: []
      operationId: getDeployReady
      responses:
        "200":
          description: Boot initialization resolved — safe to receive traffic
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    example: ready
        "503":
          description: Still starting (boot initialization has not resolved yet)
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    example: starting

  /llms.txt:
    get:
      tags: [Admin]
      summary: Machine-readable API documentation
      security: []
      operationId: getLlmsTxt
      responses:
        "200":
          description: Plain text API summary for LLM context
          content:
            text/plain:
              schema:
                type: string

  # ─── Signals ─────────────────────────────────────────────────────────

  /hot:
    get:
      tags: [Signals]
      summary: Trending markets ranked by heat score
      description: Returns markets sorted by composite heat score (volume spike, liquidity, order flow, activity, urgency).
      operationId: getHot
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 10
        - name: category
          in: query
          description: Filter by tag category (e.g., "politics", "sports", "crypto")
          schema:
            type: string
        - name: min_volume
          in: query
          description: Minimum 24h volume in USD
          schema:
            type: number
            minimum: 0
        - name: platform
          in: query
          description: Filter by platform
          schema:
            type: string
            enum: [polymarket, kalshi]
      responses:
        "200":
          description: Ranked list of hot markets
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HotResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /heat:
    post:
      tags: [Signals]
      summary: Single-market deep dive signal analysis
      description: Full signal breakdown for a specific market including heat score, whale activity, order book, and Manifold probability. Subject to a stricter per-route cap on top of the global per-key limit.
      operationId: getHeat
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HeatRequest"
      responses:
        "200":
          description: Deep signal analysis for the market
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HeatResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /signals:
    get:
      tags: [Signals]
      summary: 6-factor signal analysis
      description: Composite signal engine analyzing momentum, volume, whale activity, smart money, order book, and technical indicators. Returns directional bias with per-signal confidence.
      operationId: getSignals
      x-required-plan: pro
      parameters:
        - name: market_id
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Multi-factor signal analysis
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MarketSignals"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/PlanUpgradeRequired"
        "404":
          $ref: "#/components/responses/NotFound"

  # ─── Predictions ─────────────────────────────────────────────────────

  /predict:
    get:
      tags: [Predictions]
      summary: AI probability prediction (resolved market model)
      description: CatBoost model prediction for a market. Trained on resolved market features.
      operationId: getPredict
      x-required-plan: pro
      parameters:
        - name: market_id
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Model prediction
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PredictResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/PlanUpgradeRequired"
        "502":
          description: Prediction engine unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: |
            Engine returned a downstream service-unavailable. Different from
            502 (engine itself unreachable): 503 means engine is reachable
            but the feature service it depends on isn't, or no features
            exist yet for the requested market. The route proxies the
            engine's status code, so this can surface as either.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

  /live-predict:
    get:
      tags: [Predictions]
      summary: Live mispricing signal for active markets
      description: |
        Computes live features for an active market, enriches with order book and Manifold data,
        and returns CatBoost probability vs market price. The divergence field shows the mispricing signal.
        Falls back to feature-only response if the ML engine is unavailable.
      operationId: getLivePredict
      x-required-plan: pro
      parameters:
        - name: market_id
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Live prediction with mispricing signal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LivePredictResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/PlanUpgradeRequired"
        "404":
          $ref: "#/components/responses/NotFound"

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

  /risk-score:
    get:
      tags: [Analytics]
      summary: Market risk grade (A-F)
      description: 6-factor risk assessment grading resolution clarity, liquidity, time risk, volume quality, spread, and category risk.
      operationId: getRiskScore
      x-required-plan: pro
      parameters:
        - name: market_id
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Risk assessment with grade and factor breakdown
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RiskScore"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/PlanUpgradeRequired"
        "404":
          $ref: "#/components/responses/NotFound"

  /arbitrage:
    get:
      tags: [Analytics]
      summary: Arbitrage opportunity scanner
      description: Scans all cached markets for intra-market (YES+NO < $1 after fees) and correlated (similar questions with price discrepancy) profit opportunities.
      operationId: getArbitrage
      x-required-plan: pro
      parameters:
        - name: limit
          in: query
          description: Maximum opportunities per category
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          description: Arbitrage opportunities sorted by net profit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ArbitrageResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/PlanUpgradeRequired"

  /insider-score:
    get:
      tags: [Analytics]
      summary: Wallet insider risk score (0-100)
      description: 4-factor risk scoring for a wallet address — wallet age, position size, win rate anomaly, and trading pattern.
      operationId: getInsiderScore
      x-required-plan: pro
      parameters:
        - name: wallet
          in: query
          required: true
          description: Wallet address (e.g., 0x...)
          schema:
            type: string
      responses:
        "200":
          description: Insider risk assessment
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InsiderScore"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/PlanUpgradeRequired"
        "404":
          $ref: "#/components/responses/NotFound"

  /clusters:
    get:
      tags: [Analytics]
      summary: Wallet entity clustering
      description: Detects likely related wallets via timing correlation (trades within 30s) and market overlap (Jaccard >70%). Background pre-computed every 30 min.
      operationId: getClusters
      x-required-plan: pro
      parameters:
        - name: limit
          in: query
          description: Maximum clusters per category
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          description: Detected wallet clusters
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ClusterResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/PlanUpgradeRequired"

  # ─── Data ────────────────────────────────────────────────────────────

  /smart-money:
    get:
      tags: [Data]
      summary: Smart money wallet leaderboard
      description: Top wallets ranked by win rate across resolved markets.
      operationId: getSmartMoney
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: min_trades
          in: query
          description: Minimum resolved trades
          schema:
            type: integer
            minimum: 1
            default: 10
        - name: min_win_rate
          in: query
          description: Minimum win rate (0-1)
          schema:
            type: number
            minimum: 0
            maximum: 1
            default: 0.6
      responses:
        "200":
          description: Smart money wallet leaderboard
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SmartMoneyResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /divergence:
    get:
      tags: [Data]
      summary: Cross-venue price divergence
      description: Price gaps between Polymarket and Kalshi for the same event (sports only, Phase 1).
      operationId: getDivergence
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: min_divergence
          in: query
          description: Minimum absolute price divergence (0-1)
          schema:
            type: number
            minimum: 0
            default: 0
        - name: sport
          in: query
          description: Filter by sport (nba, nhl, mlb, nfl, etc.)
          schema:
            type: string
      responses:
        "200":
          description: Cross-venue price divergences
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DivergenceResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /export:
    get:
      tags: [Data]
      summary: ML training data export
      description: Labeled feature data for resolved markets. 22 features per market including OFI, VWAP, smart money, and trade microstructure.
      operationId: getExport
      x-required-plan: pro
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 10000
            default: 1000
        - name: min_trades
          in: query
          description: Minimum trade count per market
          schema:
            type: integer
            minimum: 0
            default: 0
        - name: format
          in: query
          description: Response format
          schema:
            type: string
            enum: [json, csv]
            default: json
        - name: category
          in: query
          description: Filter by market category
          schema:
            type: string
        - name: condition_id
          in: query
          description: Export a specific market by condition ID
          schema:
            type: string
        - name: price_min
          in: query
          description: Minimum median price at trade (0-1, filters easy markets)
          schema:
            type: number
            minimum: 0
            maximum: 1
        - name: price_max
          in: query
          description: Maximum median price at trade (0-1)
          schema:
            type: number
            minimum: 0
            maximum: 1
        - name: sliced
          in: query
          description: Return time-sliced features at 25/50/75% lifecycle (for live model training)
          schema:
            type: boolean
            default: false
        - name: complete
          in: query
          description: Return only rows whose 3 late-arriving feature phases have all completed (late_money_volume_frac, price_momentum, wallet_hhi non-NULL). Default false returns rows as soon as they exist, which may still be mid-backfill on those features.
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: Feature data (JSON or CSV)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExportResponse"
            text/csv:
              schema:
                type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/PlanUpgradeRequired"

  # ─── Admin ───────────────────────────────────────────────────────────
  # Note: POST /retrain and GET /db-stats are internal admin endpoints
  # gated by an internal Bearer token; they are intentionally not part of
  # the public OpenAPI surface.

  /metrics:
    get:
      tags: [Admin]
      summary: System health metrics
      description: Cache stats, DB activity, model info, and background job status.
      operationId: getMetrics
      responses:
        "200":
          description: System metrics
          content:
            application/json:
              schema:
                type: object
        "401":
          $ref: "#/components/responses/Unauthorized"

  # Note: /dashboard is the customer / session-authenticated browser
  # dashboard, not part of the public API surface — it's not documented
  # here. The previously-listed system-health /dashboard route is dead
  # (shadowed at startup by the customer route); operators use
  # /admin/system-health, also session-authed and not part of the
  # customer-facing API.

  # ─── Basic Data (free tier accessible) ───────────────────────────────

  /markets:
    get:
      tags: [Data]
      summary: List active markets (cursor-paginated)
      description: |
        Paginated list of active prediction markets from Polymarket + Kalshi.
        Pure cache-backed; updates every 60s. Forward-only keyset pagination
        on `(volume24hr DESC, conditionId DESC)` — pass the returned
        `next_cursor` as `?cursor=<token>` to fetch the next page.
      operationId: getMarkets
      parameters:
        - name: limit
          in: query
          description: Page size (1–500, default 50)
          schema: { type: integer, minimum: 1, maximum: 500, default: 50 }
        - name: platform
          in: query
          description: Filter by platform
          schema: { type: string, enum: [polymarket, kalshi] }
        - name: category
          in: query
          description: Substring match against market tags (case-insensitive)
          schema: { type: string }
        - name: min_volume
          in: query
          description: Minimum 24h volume in USD
          schema: { type: integer, minimum: 0 }
        - name: cursor
          in: query
          description: Opaque keyset cursor token returned by the previous page
          schema: { type: string }
      responses:
        "200":
          description: Active markets list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MarketsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /markets/{condition_id}:
    get:
      tags: [Data]
      summary: Get market by condition ID
      description: |
        Single market detail. Falls back to `market_resolutions` if the market
        has closed and is no longer in the live cache.
      operationId: getMarket
      parameters:
        - name: condition_id
          in: path
          required: true
          description: Polymarket conditionId or Kalshi market identifier
          schema: { type: string }
      responses:
        "200":
          description: Market detail w/ optional resolution
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MarketDetailResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /prices:
    get:
      tags: [Data]
      summary: Bulk current prices
      description: Returns current YES/NO prices for up to 50 markets by condition_id.
      operationId: getPrices
      parameters:
        - name: ids
          in: query
          required: true
          schema: { type: string }
          description: Comma-separated condition_ids, max 50
      responses:
        "200":
          description: Bulk price lookup
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PricesResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /orderbook/{condition_id}:
    get:
      tags: [Data]
      summary: Current order book
      description: |
        Top-100 markets by volume are kept warm by `orderBookCache` (5-min
        refresh job). Long-tail markets return **404** — we deliberately do
        NOT live-fall-back to a CLOB POST /books fetch because the only token
        identifier we have on the cached Market is Dome's `side_a.id`, which
        is not a CLOB token ID. Proper long-tail support requires resolving
        `clobTokenIds` via the Gamma API (tracked as a follow-up).
      operationId: getOrderbook
      parameters:
        - name: condition_id
          in: path
          required: true
          description: Polymarket conditionId. Top-100 markets by volume only; long-tail returns 404.
          schema: { type: string }
      responses:
        "200":
          description: Order book features
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderbookResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /trades:
    get:
      tags: [Data]
      summary: Recent trades for a market
      description: |
        Keyset pagination on composite `(timestamp DESC, id DESC)` within
        `condition_id`. Pass the returned `next_cursor` to advance the page.
        Use `since` for forward-only consumers (unix **seconds** — matches
        the trades-table storage unit).
      operationId: getTrades
      parameters:
        - name: market_id
          in: query
          required: true
          description: Polymarket conditionId to query trades for
          schema: { type: string }
        - name: limit
          in: query
          description: Page size (1–500, default 100)
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
        - name: cursor
          in: query
          description: Opaque keyset cursor returned by the previous page
          schema: { type: string }
        - name: since
          in: query
          description: |
            Unix seconds. Only trades with `timestamp >= since` are returned.
            The trades-table stores Unix seconds — passing milliseconds will
            silently exclude every real row.
          schema: { type: integer, minimum: 0 }
      responses:
        "200":
          description: Recent trades w/ keyset cursor
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TradesResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /resolutions:
    get:
      tags: [Data]
      summary: Recently resolved markets
      description: |
        Recently resolved markets with their `winning_side` outcome.
        Reads from the `market_resolutions` table; ordered by `resolved_at DESC`.
      operationId: getResolutions
      parameters:
        - name: limit
          in: query
          description: Page size (1–500, default 50)
          schema: { type: integer, minimum: 1, maximum: 500, default: 50 }
        - name: since
          in: query
          description: ISO 8601 date string or unix milliseconds — only resolutions on or after this point.
          schema: { type: string }
      responses:
        "200":
          description: Resolved markets w/ outcomes
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ResolutionsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /open-interest:
    get:
      tags: [Data]
      summary: Polymarket aggregate or per-market open interest
      description: Proxies Polymarket Data API `/oi` with 60s cache.
      operationId: getOpenInterest
      parameters:
        - name: market_id
          in: query
          description: When omitted, returns aggregate OI across Polymarket. When set, returns per-market OI.
          schema: { type: string }
      responses:
        "200":
          description: Open interest USD
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenInterestResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          $ref: "#/components/responses/UpstreamUnavailable"

  /holders/{condition_id}:
    get:
      tags: [Data]
      summary: Top wallets per market (enriched with win-rate)
      description: |
        Proxies Polymarket Data API `/holders` and joins with our `wallet_stats`
        table so each holder includes win-rate + total-trades inline.
      operationId: getHolders
      parameters:
        - name: condition_id
          in: path
          required: true
          description: Polymarket conditionId of the market whose holders to fetch
          schema: { type: string }
        - name: limit
          in: query
          description: Number of top holders to return (1–100, default 50)
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        "200":
          description: Holders w/ optional wallet_stats enrichment
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HoldersResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          $ref: "#/components/responses/UpstreamUnavailable"

  /wallet/{address}:
    get:
      tags: [Data]
      summary: Wallet reputation (from our tracked stats)
      description: |
        Win-rate, trade count, total notional, and PnL from our `wallet_stats`
        table. Address is lowercased before lookup. 404 when wallet not tracked.
      operationId: getWallet
      parameters:
        - name: address
          in: path
          required: true
          description: Polymarket proxy wallet address (case-insensitive)
          schema: { type: string }
      responses:
        "200":
          description: Wallet stats
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WalletStatsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

  /wallet/{address}/positions:
    get:
      tags: [Data]
      summary: Wallet open positions w/ unrealized PnL
      description: Proxies Polymarket Data API `/positions`, cached 30s.
      operationId: getWalletPositions
      parameters:
        - name: address
          in: path
          required: true
          description: Polymarket proxy wallet address (case-insensitive)
          schema: { type: string }
      responses:
        "200":
          description: Open positions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/WalletPositionsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          $ref: "#/components/responses/UpstreamUnavailable"

  /leaderboard:
    get:
      tags: [Data]
      summary: Top traders (enriched with win-rate)
      description: Proxies Polymarket Data API `/v1/leaderboard`, cached 5 min.
      operationId: getLeaderboard
      parameters:
        - name: limit
          in: query
          description: Number of top traders to return (1–100, default 50)
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: order
          in: query
          description: Sort key — `PNL` (default) or `VOLUME`
          schema: { type: string, enum: [PNL, VOLUME], default: PNL }
      responses:
        "200":
          description: Leaderboard
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LeaderboardResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          $ref: "#/components/responses/UpstreamUnavailable"

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: x-api-key
      description: API key passed as `x-api-key` header (preferred).
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key
      description: |
        Same API key passed as `Authorization: Bearer <key>`. Accepted as a
        fallback alongside `x-api-key` — convenient for SDKs and generated
        code that default to Bearer auth. Both are bucketed per-key by the
        rate limiter.

  responses:
    BadRequest:
      description: Invalid request parameters
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    PlanUpgradeRequired:
      description: |
        Caller is authenticated but lacks the plan tier required for this
        endpoint. Returned with HTTP 403 and `X-Scanna-Reason:
        plan_upgrade_required` header. NOTE: 402 is reserved for the x402
        micropayment challenge flow; do not conflate the two.
      headers:
        X-Scanna-Reason:
          schema:
            type: string
            enum: [plan_upgrade_required]
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/PlanUpgradeRequiredError"
    UpstreamUnavailable:
      description: |
        An upstream dependency (Polymarket Data API, CLOB, etc.) is
        temporarily unreachable. Response may include `X-Data-Freshness:
        stale-since-<iso>` when a cached fallback was attempted.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"

  schemas:
    # ─── Error ─────────────────────────────────────────────────────────

    ErrorResponse:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
          example: invalid_request
        message:
          type: string
          example: market_id required

    PlanUpgradeRequiredError:
      type: object
      required: [error, code, message, current_plan, required_plan, upgrade_url]
      properties:
        error:
          type: string
          enum: [FORBIDDEN]
        code:
          type: string
          enum: [plan_upgrade_required]
        message:
          type: string
        current_plan:
          type: string
          nullable: true
          enum: [free, pro, enterprise, admin]
        required_plan:
          type: string
          enum: [pro, enterprise, admin]
        required_plans:
          type: array
          items:
            type: string
        upgrade_url:
          type: string
          format: uri

    # ─── Heat / Signals ────────────────────────────────────────────────

    HeatRequest:
      type: object
      required: [market_id]
      properties:
        market_id:
          type: string
          description: Condition ID of the market
        lookback_minutes:
          type: integer
          minimum: 1
          default: 240
          description: Minutes of trade history to analyze

    WhaleActivity:
      type: object
      required: [count, total_notional, direction, smart_money_count]
      properties:
        count:
          type: integer
        total_notional:
          type: number
          description: Total whale trade value in USD
        direction:
          type: string
          enum: [buy, sell, mixed]
        smart_money_count:
          type: integer
          description: How many whale trades came from high-win-rate wallets

    HeatResponse:
      type: object
      required: [market_id, platform, question, heat_score, volume_spike_x, liquidity_usd, volume_lookback, volume_24h, flow_bias, flow_score, volatility, price_yes, price_no, signals]
      properties:
        market_id:
          type: string
        platform:
          type: string
          enum: [polymarket, kalshi]
        question:
          type: string
        heat_score:
          type: number
          description: Composite heat score (0-1)
        volume_spike_x:
          type: number
          description: Volume spike multiplier vs baseline
        hours_to_resolve:
          type: ["integer", "null"]
        liquidity_usd:
          type: number
        whale_activity:
          oneOf:
            - $ref: "#/components/schemas/WhaleActivity"
            - type: "null"
        volume_lookback:
          type: number
        volume_24h:
          type: number
        flow_bias:
          type: string
          enum: [buy, sell, neutral]
        flow_score:
          type: number
          description: Raw flow ratio (-1 to +1)
        volatility:
          type: string
          enum: [low, medium, high]
        price_yes:
          type: number
          description: YES price (0-1)
        price_no:
          type: number
          description: NO price (0-1)
        signals:
          type: array
          items:
            type: string
          description: Human-readable signal descriptions
        order_book:
          type: ["object", "null"]
          properties:
            imbalance:
              type: number
            spread_bps:
              type: number
            bid_depth_ratio:
              type: number
            depth_usd_5pct:
              type: number
        manifold_probability:
          type: ["number", "null"]
          description: Cross-platform probability from Manifold Markets

    HotMarketEntry:
      type: object
      required: [market_id, platform, question, heat_score, volume_spike_x, liquidity_usd, volume_24h, price_yes, flow_bias, tags, signals]
      properties:
        market_id:
          type: string
        platform:
          type: string
          enum: [polymarket, kalshi]
        question:
          type: string
        heat_score:
          type: number
        volume_spike_x:
          type: number
        hours_to_resolve:
          type: ["integer", "null"]
        liquidity_usd:
          type: number
        whale_activity:
          oneOf:
            - $ref: "#/components/schemas/WhaleActivity"
            - type: "null"
        volume_24h:
          type: number
        price_yes:
          type: number
        flow_bias:
          type: string
          enum: [buy, sell, neutral]
        tags:
          type: array
          items:
            type: string
        end_date:
          type: ["string", "null"]
          format: date-time
        signals:
          type: array
          items:
            type: string

    HotResponse:
      type: object
      required: [markets, updated_at]
      properties:
        markets:
          type: array
          items:
            $ref: "#/components/schemas/HotMarketEntry"
        updated_at:
          type: string
          format: date-time

    # ─── Market Signals ────────────────────────────────────────────────

    Signal:
      type: object
      required: [type, direction, strength, confidence, weight, description]
      properties:
        type:
          type: string
          description: Signal type (momentum, volume, whale, smart_money, order_book, technical)
        direction:
          type: string
          enum: [bullish, bearish, neutral]
        strength:
          type: number
          description: Signal strength (0-1)
        confidence:
          type: number
          description: Confidence in signal (0-1)
        weight:
          type: number
          description: Weight in overall composite
        description:
          type: string

    MarketSignals:
      type: object
      required: [market_id, question, direction, probability_change, confidence, signal_count, signal_summary, signals]
      properties:
        market_id:
          type: string
        question:
          type: string
        direction:
          type: string
          enum: [bullish, bearish, neutral]
        probability_change:
          type: number
          description: Estimated probability shift (-10 to +10)
        confidence:
          type: number
          description: Weighted confidence (0-1)
        signal_count:
          type: integer
        signal_summary:
          type: object
          required: [bullish, bearish, neutral]
          properties:
            bullish:
              type: integer
            bearish:
              type: integer
            neutral:
              type: integer
        signals:
          type: array
          items:
            $ref: "#/components/schemas/Signal"

    # ─── Predictions ───────────────────────────────────────────────────

    PredictResponse:
      type: object
      properties:
        market_id:
          type: string
        predicted_probability:
          type: number
          description: Model probability (0-1)
        confidence_interval:
          type: array
          items:
            type: number
          description: "[low, high] confidence bounds"
        model_source:
          type: string
        converged:
          type: boolean
        features_used:
          type: integer

    LivePredictResponse:
      type: object
      required: [market_id]
      properties:
        market_id:
          type: string
        question:
          type: string
        market_price:
          type: number
          description: Current market YES price (0-1)
        predicted_probability:
          type: ["number", "null"]
          description: Model probability (0-1). Null if engine unavailable.
        divergence:
          type: ["number", "null"]
          description: Mispricing signal (predicted - market price). Positive = model sees higher probability.
        divergence_pp:
          type: ["number", "null"]
          description: Divergence in percentage points (1dp). Same signal as `divergence`, scaled ×100 for display. Omitted when engine is unavailable.
        model_source:
          type: string
          description: Model that produced the prediction (catboost_live, blend, pending)
        message:
          type: ["string", "null"]
          description: Status message if engine is unavailable
        pct_lifecycle_elapsed:
          type: number
          description: Percentage of market lifecycle elapsed (0-1)
        time_remaining_hours:
          type: number
          description: Hours until market resolution
        features_snapshot:
          type: object
          description: Raw feature values used for prediction
          additionalProperties: true

    # ─── Risk Score ────────────────────────────────────────────────────

    RiskFactor:
      type: object
      required: [score, weight, reason]
      properties:
        score:
          type: number
          description: Factor score (0-100)
        weight:
          type: number
          description: Weight in overall score (sums to 1)
        reason:
          type: string
          description: Human-readable explanation

    RiskScore:
      type: object
      required: [market_id, question, grade, overall_score, factors, warnings]
      properties:
        market_id:
          type: string
        question:
          type: string
        grade:
          type: string
          enum: [A, B, C, D, F]
          description: "Risk grade (A=low risk, F=high risk)"
        overall_score:
          type: integer
          description: Overall risk score (0-100)
        factors:
          type: object
          required: [resolution_clarity, liquidity, time_risk, volume_quality, spread, category_risk]
          properties:
            resolution_clarity:
              $ref: "#/components/schemas/RiskFactor"
            liquidity:
              $ref: "#/components/schemas/RiskFactor"
            time_risk:
              $ref: "#/components/schemas/RiskFactor"
            volume_quality:
              $ref: "#/components/schemas/RiskFactor"
            spread:
              $ref: "#/components/schemas/RiskFactor"
            category_risk:
              $ref: "#/components/schemas/RiskFactor"
        warnings:
          type: array
          items:
            type: string

    # ─── Arbitrage ─────────────────────────────────────────────────────

    ArbitrageOpportunity:
      type: object
      required: [type, market1_id, market1_question, market1_yes_price, market1_no_price, spread, gross_profit_pct, fee_cost, net_profit_pct, net_profit_on_100, confidence]
      properties:
        type:
          type: string
          enum: [intra_market, correlated, cross_platform]
        market1_id:
          type: string
        market1_question:
          type: string
        market1_yes_price:
          type: number
        market1_no_price:
          type: number
        market2_id:
          type: ["string", "null"]
        market2_question:
          type: ["string", "null"]
        market2_yes_price:
          type: ["number", "null"]
        spread:
          type: number
          description: Price difference
        gross_profit_pct:
          type: number
          description: Pre-fees profit percentage
        fee_cost:
          type: number
          description: Estimated fee cost (Polymarket 2%)
        net_profit_pct:
          type: number
          description: After-fees profit percentage
        net_profit_on_100:
          type: number
          description: Dollar profit per $100 bet
        confidence:
          type: string
          enum: [high, medium, low]

    ArbitrageResponse:
      type: object
      required: [intra_market, correlated, total, scanned]
      properties:
        intra_market:
          type: array
          items:
            $ref: "#/components/schemas/ArbitrageOpportunity"
        correlated:
          type: array
          items:
            $ref: "#/components/schemas/ArbitrageOpportunity"
        total:
          type: integer
          description: Total opportunities found (before limit)
        scanned:
          type: integer
          description: Total markets scanned

    # ─── Insider Score ─────────────────────────────────────────────────

    InsiderScore:
      type: object
      required: [address, risk_score, risk_level, risk_factors, total_trades, total_volume, win_rate, avg_position_size]
      properties:
        address:
          type: string
        risk_score:
          type: integer
          description: Insider risk score (0-100)
        risk_level:
          type: string
          enum: [high, medium, low]
        risk_factors:
          type: array
          items:
            type: string
          description: Detected risk indicators
        wallet_age_days:
          type: ["number", "null"]
        total_trades:
          type: integer
        total_volume:
          type: number
          description: Total trade volume in USD
        win_rate:
          type: number
          description: Win rate (0-1)
        avg_position_size:
          type: number
          description: Average USD per trade

    # ─── Clusters ──────────────────────────────────────────────────────

    WalletCluster:
      type: object
      required: [wallets, correlation_type, confidence, shared_markets, evidence]
      properties:
        wallets:
          type: array
          items:
            type: string
          description: Wallet addresses in the cluster (2+)
        correlation_type:
          type: string
          enum: [timing, market_overlap]
        confidence:
          type: number
          description: Correlation confidence (0-1)
        shared_markets:
          type: integer
        evidence:
          type: string
          description: Human-readable evidence description

    ClusterResponse:
      type: object
      required: [timing, market_overlap, total]
      properties:
        timing:
          type: array
          items:
            $ref: "#/components/schemas/WalletCluster"
        market_overlap:
          type: array
          items:
            $ref: "#/components/schemas/WalletCluster"
        total:
          type: integer
        message:
          type: ["string", "null"]
          description: Status message if clusters are still being computed

    # ─── Smart Money ───────────────────────────────────────────────────

    SmartMoneyEntry:
      type: object
      required: [address, win_rate, trades, pnl, total_notional, last_seen]
      properties:
        address:
          type: string
        win_rate:
          type: number
          description: Win rate (0-1)
        trades:
          type: integer
        pnl:
          type: number
          description: Profit/loss in USD
        total_notional:
          type: number
          description: Total trade volume in USD
        last_seen:
          type: string
          format: date-time

    SmartMoneyResponse:
      type: object
      required: [wallets, total_tracked, updated_at]
      properties:
        wallets:
          type: array
          items:
            $ref: "#/components/schemas/SmartMoneyEntry"
        total_tracked:
          type: integer
          description: Total wallets tracked in database
        updated_at:
          type: string
          format: date-time

    # ─── Divergence ────────────────────────────────────────────────────

    DivergenceEntry:
      type: object
      required: [event_key, question, sport, polymarket_slug, kalshi_ticker, poly_price, kalshi_price, divergence]
      properties:
        event_key:
          type: string
        question:
          type: string
        sport:
          type: string
        polymarket_slug:
          type: string
        kalshi_ticker:
          type: string
        poly_price:
          type: number
          description: Polymarket YES price (0-1)
        kalshi_price:
          type: number
          description: Kalshi YES price (0-1)
        divergence:
          type: number
          description: Absolute price difference

    DivergenceResponse:
      type: object
      required: [pairs, total_matched, updated_at]
      properties:
        pairs:
          type: array
          items:
            $ref: "#/components/schemas/DivergenceEntry"
        total_matched:
          type: integer
        updated_at:
          type: string
          format: date-time

    # ─── Export ────────────────────────────────────────────────────────

    FeatureRow:
      type: object
      description: 22 ML features plus outcome label and metadata for a resolved market
      properties:
        condition_id:
          type: string
        volume_total:
          type: number
        volume_24h_at_close:
          type: number
        volume_zscore:
          type: number
        trade_count:
          type: integer
        buy_ratio:
          type: number
        whale_trade_count:
          type: integer
        whale_notional_ratio:
          type: number
        smart_money_trade_count:
          type: integer
        smart_money_buy_ratio:
          type: number
        smart_money_participation:
          type: number
        hours_to_resolve:
          type: number
        unique_traders:
          type: integer
        avg_trade_size:
          type: number
        median_price_at_trade:
          type: number
        price_volatility:
          type: number
        ofi_normalized:
          type: number
          description: Order flow imbalance (dominates at 39-54% feature importance)
        vwap:
          type: number
        vwap_deviation:
          type: number
        late_money_volume_frac:
          type: number
        late_money_ofi:
          type: number
        price_momentum:
          type: number
        price_autocorr:
          type: number
        wallet_hhi:
          type: number
          description: Wallet concentration (Herfindahl-Hirschman Index)
        winning_side:
          type: string
          description: Outcome label (side_a or side_b)
        platform:
          type: string
        question:
          type: string
        computed_at:
          type: string
          format: date-time

    ExportResponse:
      type: object
      required: [features, count, schema_version, exported_at]
      properties:
        features:
          type: array
          items:
            $ref: "#/components/schemas/FeatureRow"
        count:
          type: integer
        schema_version:
          type: string
          description: "'1.0' for resolved, '2.0-live' for time-sliced"
        exported_at:
          type: string
          format: date-time

    # ─── Basic Data (Wave 1+3 endpoints) ──────────────────────────────

    MarketSummary:
      type: object
      required: [condition_id, slug, question, platform, outcome_prices, active, closed, volume_24h, volume_total, liquidity, end_date, tags, winning_side]
      properties:
        condition_id: { type: string }
        slug: { type: string }
        question: { type: string }
        platform: { type: string, enum: [polymarket, kalshi] }
        outcome_prices:
          type: array
          items: { type: number }
        active: { type: boolean }
        closed: { type: boolean }
        volume_24h: { type: number }
        volume_total: { type: number }
        liquidity: { type: number }
        end_date: { type: string }
        tags:
          type: array
          items: { type: string }
        winning_side:
          type: string
          nullable: true

    MarketsResponse:
      type: object
      required: [markets, count, total, updated_at]
      properties:
        markets:
          type: array
          items:
            $ref: "#/components/schemas/MarketSummary"
        count: { type: integer }
        total: { type: integer }
        next_cursor:
          type: string
          nullable: true
          description: Pass as `?cursor=<token>` to fetch the next page; null on last page.
        updated_at:
          type: string
          nullable: true
        _meta:
          type: object

    Resolution:
      type: object
      required: [winning_side, resolved_at]
      properties:
        winning_side: { type: string }
        volume_total:
          type: number
          nullable: true
        resolved_at: { type: string, format: date-time }

    MarketDetailResponse:
      type: object
      properties:
        market:
          oneOf:
            - $ref: "#/components/schemas/MarketSummary"
            - type: "null"
        resolution:
          oneOf:
            - $ref: "#/components/schemas/Resolution"
            - type: "null"
        _meta:
          type: object

    PriceEntry:
      type: object
      required: [condition_id, found]
      properties:
        condition_id: { type: string }
        price_yes:
          type: number
          nullable: true
        price_no:
          type: number
          nullable: true
        platform:
          type: string
          nullable: true
        found: { type: boolean }

    PricesResponse:
      type: object
      required: [prices, count]
      properties:
        prices:
          type: array
          items:
            $ref: "#/components/schemas/PriceEntry"
        count: { type: integer }
        updated_at:
          type: string
          nullable: true

    OrderbookFeatures:
      type: object
      required: [condition_id, token_id, midpoint, spread_bps, order_book_imbalance, bid_depth_ratio, depth_usd_5pct]
      properties:
        condition_id: { type: string }
        token_id: { type: string }
        midpoint: { type: number }
        spread_bps: { type: number }
        order_book_imbalance: { type: number }
        bid_depth_ratio: { type: number }
        depth_usd_5pct: { type: number }

    OrderbookResponse:
      type: object
      required: [order_book, updated_at]
      properties:
        order_book:
          $ref: "#/components/schemas/OrderbookFeatures"
        updated_at: { type: string, format: date-time }

    TradeRow:
      type: object
      required: [id, condition_id, proxy_wallet, price, size, side, notional, timestamp, is_whale]
      properties:
        id: { type: integer }
        condition_id: { type: string }
        proxy_wallet: { type: string }
        price: { type: number }
        size: { type: number }
        side: { type: string, enum: [BUY, SELL] }
        notional: { type: number }
        timestamp: { type: integer, description: "Unix seconds. Matches the trades-table storage unit." }
        is_whale: { type: boolean }

    TradesResponse:
      type: object
      required: [trades, count]
      properties:
        trades:
          type: array
          items:
            $ref: "#/components/schemas/TradeRow"
        count: { type: integer }
        next_cursor:
          type: string
          nullable: true
        _meta:
          type: object

    ResolutionRow:
      type: object
      required: [condition_id, platform, question, winning_side, resolved_at]
      properties:
        condition_id: { type: string }
        platform: { type: string }
        question: { type: string }
        winning_side: { type: string }
        volume_total:
          type: number
          nullable: true
        resolved_at: { type: string, format: date-time }

    ResolutionsResponse:
      type: object
      required: [resolutions, count]
      properties:
        resolutions:
          type: array
          items:
            $ref: "#/components/schemas/ResolutionRow"
        count: { type: integer }

    OpenInterestResponse:
      type: object
      required: [open_interest_usd]
      properties:
        open_interest_usd: { type: number }
        market_id:
          type: string
          nullable: true

    HolderStats:
      type: object
      required: [trades, pnl, win_rate]
      properties:
        trades: { type: integer }
        pnl: { type: number }
        win_rate: { type: number }

    HolderRow:
      type: object
      required: [proxy_wallet, outcome, balance, condition_id]
      properties:
        proxy_wallet: { type: string }
        outcome: { type: string }
        balance: { type: number }
        condition_id: { type: string }
        stats:
          oneOf:
            - $ref: "#/components/schemas/HolderStats"
            - type: "null"

    HoldersResponse:
      type: object
      required: [holders, count]
      properties:
        holders:
          type: array
          items:
            $ref: "#/components/schemas/HolderRow"
        count: { type: integer }

    WalletStatsResponse:
      type: object
      required: [address, trades, wins, losses, win_rate, total_notional, pnl, last_seen]
      properties:
        address: { type: string }
        trades: { type: integer }
        wins: { type: integer }
        losses: { type: integer }
        win_rate: { type: number }
        total_notional: { type: number }
        pnl: { type: number }
        last_seen: { type: string, format: date-time }

    WalletPosition:
      type: object
      required: [conditionId, outcome, size, avgPrice, currentValue, unrealizedPnl, market]
      properties:
        conditionId: { type: string }
        outcome: { type: string }
        size: { type: number }
        avgPrice: { type: number }
        currentValue: { type: number }
        unrealizedPnl: { type: number }
        market: { type: string }

    WalletPositionsResponse:
      type: object
      required: [wallet, positions, count]
      properties:
        wallet: { type: string }
        positions:
          type: array
          items:
            $ref: "#/components/schemas/WalletPosition"
        count: { type: integer }

    LeaderboardEntry:
      type: object
      # `proxy_wallet` is always present; everything else is upstream-
      # dependent (Polymarket /v1/leaderboard returns sparse rows for some
      # wallets — `volume` and `trades` can be missing/null). The route
      # forwards what the upstream gives, so the schema mirrors that.
      required: [proxy_wallet]
      properties:
        proxy_wallet: { type: string }
        name:
          type: string
          nullable: true
        pnl:
          type: number
          nullable: true
        volume:
          type: number
          nullable: true
        trades:
          type: integer
          nullable: true
        win_rate:
          type: number
          nullable: true
        trades_local:
          type: integer
          nullable: true

    LeaderboardResponse:
      type: object
      required: [leaderboard, order, count]
      properties:
        leaderboard:
          type: array
          items:
            $ref: "#/components/schemas/LeaderboardEntry"
        order: { type: string, enum: [PNL, VOLUME] }
        count: { type: integer }
