openapi: 3.0.3
info:
  title: Kimchi Premium API
  version: "1.1.0"
  description: |
    Realtime Kimchi premium matrix with FX-normalized spreads across Korean and
    Western exchanges.

    ### Authentication

    All authenticated endpoints accept either a **JWT** or an **API key** on
    the `Authorization: Bearer <token>` header. Both are issued by
    [auth.marketmaker.cc](https://auth.marketmaker.cc):

    - `POST {AUTH_BASE_URL}/auth/login` — exchange email/password for a JWT.
    - `POST {AUTH_BASE_URL}/keys` — create an API key (string `mk_live_<hex>`).

    The kimchi-specific role lives under `services["kimchi"]` in the JWT
    claims, and `admin`/`superuser` unlocks the /api/v1/admin/* routes.

    ### WebSocket

    `GET /ws/premium` upgrades to a server-pushed JSON stream
    (`{rows, ts, market_data_mode}` per frame, default 1 Hz).
servers:
  - url: /
    description: Same origin (or backend origin in dev)
tags:
  - name: health
  - name: premium
  - name: meta
  - name: auth
  - name: trade
    description: |
      Order validation for placing arbitrage trades. Currently dry-run only:
      orders are validated and a fill is projected from the live order book,
      but nothing is sent to the exchange. Live placement will read
      per-user exchange credentials vaulted in auth.marketmaker.cc.
  - name: admin
  - name: docs
paths:
  /healthz:
    get:
      tags: [health]
      summary: Liveness probe
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
  /api/v1/premium/table:
    get:
      tags: [premium]
      summary: Premium table rows (USDT-normalized)
      responses:
        "200":
          description: Rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows:
                    type: array
                    items:
                      $ref: "#/components/schemas/PremiumRow"
  /api/v1/premium/latency-stats:
    get:
      tags: [premium]
      summary: Latency percentiles across the current premium table
      description: |
        Aggregate stats over the `latency_ms` column of the current table snapshot.
        Percentiles use the nearest-rank method on integer milliseconds.
      responses:
        "200":
          description: Latency distribution
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LatencyStats"
  /api/v1/premium/simulate:
    post:
      tags: [premium]
      summary: Simulate buy open / sell close (USDT notional)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SimulateRequest"
      responses:
        "200":
          description: Result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SimulationResult"
        "400":
          description: Bad request
  /api/v1/meta/exchanges:
    get:
      tags: [meta]
      summary: Configured exchange ids
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  western:
                    type: array
                    items: { type: string }
                  korean:
                    type: array
                    items: { type: string }
  /api/v1/meta/symbols:
    get:
      tags: [meta]
      summary: Discovered base symbols (Korean ∩ Western)
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  symbols:
                    type: array
                    items: { type: string }
  /api/v1/meta/status:
    get:
      tags: [meta]
      summary: Backend mode, version, and auth base URL
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  market_data_mode: { type: string, example: realtime }
                  backend_version: { type: string }
                  auth_base_url:
                    type: string
                    example: https://auth.marketmaker.cc/api/v1
  /api/v1/auth/me:
    get:
      tags: [auth]
      summary: Current principal (JWT or API key)
      description: |
        Returns the resolved principal for the supplied bearer token. The
        kimchi-specific role is in `role`; the raw multi-service map is in
        `services`. Useful for the frontend to render its admin UI gates.
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthPrincipal" }
        "401": { description: Unauthorized }
  /api/v1/admin/feed-connections:
    get:
      tags: [admin]
      summary: WS/REST feed connection snapshot
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  connections:
                    type: array
                    items: { $ref: "#/components/schemas/FeedConnection" }
  /api/v1/admin/settings:
    get:
      tags: [admin]
      summary: Get app settings
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/AppSetting" }
    put:
      tags: [admin]
      summary: Update app setting
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AppSetting" }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppSetting" }
  /ws/premium:
    get:
      tags: [premium]
      summary: WebSocket stream of premium rows
      description: |
        Upgrades to a WebSocket. The server pushes a JSON frame
        `{rows, ts, market_data_mode}` once per `WS_PUSH_INTERVAL_MS`
        (default 1000). A single broadcast hub fans out the same payload to
        every connected client, so CPU does not scale with subscriber count.
      responses:
        "101": { description: Switching Protocols }
  /api/v1/trade/exchanges:
    get:
      tags: [trade]
      summary: Supported trading venues and their capabilities
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  mode: { type: string, example: dry_run }
                  note: { type: string }
                  exchanges:
                    type: array
                    items: { $ref: "#/components/schemas/ExchangeInfo" }
  /api/v1/trade/order/validate:
    post:
      tags: [trade]
      summary: Dry-run validate a single order
      description: |
        Validates an order and projects the expected fill from the current
        top-of-book: projected price, quantity, gross cost, estimated taker
        fee, and any warnings (stale book, below minimum notional, a limit
        order that would not fill immediately). No order is placed.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TradeOrder" }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DryRunResult" }
        "400": { description: Invalid order }
        "401": { description: Unauthorized }
  /api/v1/trade/arbitrage/validate:
    post:
      tags: [trade]
      summary: Dry-run validate both legs of an arbitrage trade
      description: |
        Validates an open leg and a close leg together. Each leg is projected
        independently; cross-leg checks flag a base-asset mismatch, same-side
        legs, or both legs on one exchange.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [open, close]
              properties:
                open: { $ref: "#/components/schemas/TradeOrder" }
                close: { $ref: "#/components/schemas/TradeOrder" }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ArbitrageDryRun" }
        "400": { description: Invalid order }
        "401": { description: Unauthorized }
  /api/v1/trade/order:
    post:
      tags: [trade]
      summary: Place a live order (when TRADING_LIVE_ENABLED=true)
      description: |
        Sends a real order to the exchange using API credentials vaulted in
        auth.marketmaker.cc. Requires the user to have stored credentials for
        the target exchange. Returns the exchange-assigned order ID on success.
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TradeOrder" }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrderResult" }
        "400": { description: Invalid order }
        "401": { description: Unauthorized }
        "503": { description: Live trading disabled or credentials missing }
  /api/docs:
    get:
      tags: [docs]
      summary: Redoc UI
      responses:
        "200": { description: HTML }
  /api/v1/openapi.json:
    get:
      tags: [docs]
      summary: Download this spec as JSON
      responses:
        "200":
          content:
            application/json: {}
  /api/v1/openapi.yaml:
    get:
      tags: [docs]
      summary: Download this spec as YAML
      responses:
        "200":
          content:
            application/yaml: {}

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT or mk_live_<hex>
  schemas:
    LatencyStats:
      type: object
      properties:
        count: { type: integer }
        min:   { type: integer }
        p50:   { type: integer }
        p75:   { type: integer }
        p90:   { type: integer }
        p95:   { type: integer }
        p99:   { type: integer }
        max:   { type: integer }
        mean:  { type: integer }
    PremiumRow:
      type: object
      properties:
        asset: { type: string }
        symbol: { type: string }
        open_symbol: { type: string }
        close_symbol: { type: string }
        open_exchange: { type: string }
        close_exchange: { type: string }
        open_price_usdt: { type: number }
        close_price_usdt: { type: number }
        spread_pct: { type: number }
        spread_usdt: { type: number }
        anomaly: { type: boolean }
        volume_median_usd: { type: number }
        open_vol_pct:
          type: number
          description: Windowed high-low range (%) of the open leg's market.
        close_vol_pct:
          type: number
          description: Windowed high-low range (%) of the close leg's market.
        asset_vol_pct:
          type: number
          description: Worst-case volatility across every venue for this base coin — high values flag a thin / shitcoin market.
        simulation_at: { type: string, format: date-time }
        simulation_pnl_usdt: { type: number }
        simulation_notional_usdt: { type: number }
        fx_rate: { type: number }
        latency_ms: { type: integer }
        updated_at: { type: string, format: date-time }
    SimulateRequest:
      type: object
      required: [open_exchange, close_exchange, open_symbol, close_symbol]
      properties:
        open_exchange: { type: string }
        close_exchange: { type: string }
        open_symbol: { type: string }
        close_symbol: { type: string }
        notional_usdt: { type: number }
    SimulationResult:
      type: object
      properties:
        open_exchange: { type: string }
        close_exchange: { type: string }
        open_symbol: { type: string }
        close_symbol: { type: string }
        notional_usdt: { type: number }
        open_price_usdt: { type: number }
        close_price_usdt: { type: number }
        pnl_usdt: { type: number }
        computed_at: { type: string, format: date-time }
    AuthPrincipal:
      type: object
      properties:
        user_id: { type: string, format: uuid }
        email: { type: string, format: email }
        username: { type: string }
        role:
          type: string
          enum: [user, admin, superuser]
          description: Role for the "kimchi" service.
        services:
          type: object
          additionalProperties: { type: string }
          example: { kimchi: admin, warehouse: user }
        auth_type:
          type: string
          enum: [jwt, api_key]
    AppSetting:
      type: object
      properties:
        key: { type: string }
        value: { type: string }
        updated_at: { type: string, format: date-time }
    FeedConnection:
      type: object
      properties:
        id: { type: string }
        exchange: { type: string }
        transport: { type: string, enum: [websocket, http_poll, internal_mock] }
        endpoint: { type: string }
        detail: { type: string }
        status: { type: string }
        connected_at: { type: string, format: date-time }
        last_activity: { type: string, format: date-time }
        messages_approx: { type: integer }
        reconnects: { type: integer }
    ExchangeInfo:
      type: object
      properties:
        name: { type: string, example: binance }
        order_types:
          type: array
          items: { type: string, enum: [market, limit] }
        quote_assets:
          type: array
          items: { type: string }
        taker_fee_pct: { type: number, example: 0.1 }
        min_notional:
          type: object
          additionalProperties: { type: number }
          description: Minimum order value keyed by quote asset.
        live_trading:
          type: boolean
          description: true when live placement is enabled for this venue.
    TradeOrder:
      type: object
      required: [exchange, symbol, side, type]
      properties:
        exchange: { type: string, enum: [binance, upbit] }
        symbol:
          type: string
          description: Canonical BASE/QUOTE.
          example: BTC/USDT
        side: { type: string, enum: [buy, sell] }
        type: { type: string, enum: [market, limit] }
        quantity:
          type: number
          description: Base amount. Provide this or notional.
        price:
          type: number
          description: Limit price — required for limit orders.
        notional:
          type: number
          description: Quote amount — alternative sizing for market orders.
    DryRunResult:
      type: object
      properties:
        mode: { type: string, example: dry_run }
        exchange: { type: string }
        symbol: { type: string }
        exchange_symbol:
          type: string
          description: Venue-native symbol the real order would use (e.g. BTCUSDT, KRW-BTC).
        side: { type: string }
        type: { type: string }
        quote_asset: { type: string }
        reference_bid: { type: number }
        reference_ask: { type: number }
        book_age_ms: { type: integer }
        projected_price: { type: number }
        projected_qty: { type: number }
        projected_cost:
          type: number
          description: Gross quote spent (buy) or received (sell).
        est_fee_pct: { type: number }
        est_fee_quote: { type: number }
        net_cost:
          type: number
          description: Cost incl. fee (buy) or proceeds net of fee (sell).
        marketable:
          type: boolean
          description: For limit orders — whether it would fill immediately.
        valid: { type: boolean }
        warnings:
          type: array
          items: { type: string }
    ArbitrageDryRun:
      type: object
      properties:
        mode: { type: string, example: dry_run }
        open: { $ref: "#/components/schemas/DryRunResult" }
        close: { $ref: "#/components/schemas/DryRunResult" }
        valid: { type: boolean }
        notes:
          type: array
          items: { type: string }
    OrderResult:
      type: object
      properties:
        mode: { type: string, example: live }
        exchange: { type: string }
        symbol: { type: string }
        exchange_symbol: { type: string }
        side: { type: string }
        type: { type: string }
        projected_price: { type: number }
        projected_qty: { type: number }
        projected_cost: { type: number }
        est_fee_pct: { type: number }
        est_fee_quote: { type: number }
        net_cost: { type: number }
        valid: { type: boolean }
        warnings:
          type: array
          items: { type: string }
        order_id: { type: string }
        status: { type: string }
        raw_response:
          type: string
          description: Raw JSON response from the exchange.
    Credentials:
      type: object
      properties:
        api_key: { type: string }
        api_secret: { type: string }
        passphrase: { type: string }
