# Rulyfi Backtest API — OpenAPI 3.1 specification
#
# Source of truth: packages/contracts/src/publicApi.ts (zod schemas, enums, error
#   codes, and metering constants). This YAML is kept in sync by hand and verified
#   against publicApi.ts by a drift check that compares every enum, the error-code
#   set, the metering constants (x-rulyfi-limits below), and the endpoint paths.
#   If you change publicApi.ts, re-run that check and update this file.
#
# Public contract != internal wire (plan D3): internal fields (userId, tier,
#   totalVariants, bypassCache, sortBy) never appear here — the server derives them.
openapi: 3.1.0

info:
  title: Rulyfi Backtest API
  version: "1.0"
  summary: Backtest and scan crypto trading strategies with honest statistics (PSR, deflated Sharpe, full-population num_trials).
  description: |
    The Rulyfi Backtest API runs no-code crypto strategies against real market data
    and returns honest statistics — every backtest carries a Probabilistic Sharpe
    Ratio (PSR), and every scan reports the deflated Sharpe threshold and the number
    of trials across the full evaluated population, so a strategy that only looks
    good because it was cherry-picked from thousands of trials is exposed.

    The strategy object format is documented separately at
    https://rulyfi.com/strategy-schema.md — every field, the full 103-indicator
    catalog, the condition grammar, and the percent-notation unit convention.

    All numeric percent fields use **percent notation**: `commission_taker_pct: 0.04`
    means 0.04%, `take_profit: 3` means +3%.

    The backtest engine runs entirely on Rulyfi's servers. The `@rulyfi/mcp`
    package is a thin API client and candle-fetch orchestrator — it contains no
    strategy engine.

    **Single-backtest calls (`POST /v1/backtests`) are spot-market only.** For
    futures, walk-forward efficiency across a parameter grid, and full-population
    exports, use the scan endpoints.
  contact:
    email: support@rulyfi.com

servers:
  - url: https://api.rulyfi.com/v1
    description: Production

# Machine-checkable mirror of the metering/volume constants in publicApi.ts.
# The verify-openapi script asserts these equal LANE1_RATE_PER_MIN, LANE1_BLOCK_SIZE,
# INLINE_CANDLE_MAX, CANDLE_WIRE_VERSION, and LANE1_DAILY_CALL_CAP.
x-rulyfi-limits:
  lane1_rate_per_min: 60
  lane1_block_size: 1000
  inline_candle_max: 5000
  candle_wire_version: v4-gzip
  lane1_daily_call_cap:
    plus: 5000
    pro: 20000
    premium: 50000
  per_key_concurrent_scans: 2

security:
  - bearerAuth: []

tags:
  - name: Catalog
    description: Static reference data (free, no execution).
  - name: Validate
    description: Schema, tier, and cost checks with no execution (free).
  - name: Candles
    description: candle_ref issuance (upload-once, reference-many).
  - name: Backtests
    description: "Lane 1: a single backtest of an arbitrary strategy (spot-market only)."
  - name: Scans
    description: "Lane 2: a card x grid parameter sweep with full-population statistics."
  - name: Jobs
    description: Scan job status, results, and full-population export.

paths:
  /catalog/indicators:
    get:
      tags: [Catalog]
      operationId: getIndicatorCatalog
      summary: List the indicator catalog (103 indicators).
      description: |
        Every indicator type with its parameters (key, default, range), outputs,
        and English description, plus the condition operators and price fields.
        Free.
      responses:
        "200":
          description: The full catalog.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IndicatorCatalogResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /catalog/symbols:
    get:
      tags: [Catalog]
      operationId: getSymbolCatalog
      summary: List supported exchanges, timeframes, and directions.
      description: The exchange x symbol x timeframe support matrix. Free.
      responses:
        "200":
          description: Supported markets.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SymbolCatalogResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /validate:
    post:
      tags: [Validate]
      operationId: validate
      summary: Validate a strategy or a scan request (free, no execution).
      description: |
        Returns every error at once (not just the first), each with a field path,
        a human-readable message, and a correct example — this is the correction
        loop for porting a Python/TradingView strategy into the Rulyfi format.
        No credits are charged and nothing is executed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/ValidateStrategyRequest"
                - $ref: "#/components/schemas/ValidateScanRequest"
      responses:
        "200":
          description: Validation result. `valid` is false when `errors` is non-empty.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ValidateResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/PaidTierRequired" }

  /candles:
    post:
      tags: [Candles]
      operationId: issueCandleRef
      summary: Get a candle_ref (presigned upload, or a cache hit).
      description: |
        Request a candle_ref for a market and range. If the server already has the
        candles cached for your account, `cached` is true and no upload is needed.
        Otherwise `upload_url` is returned; PUT the gzip-encoded candle wire
        (`wire_format: v4-gzip`) to it, then pass the `candle_ref` to
        `POST /v1/backtests`. Candle references are scoped to your account. Free.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CandlesRequest" }
      responses:
        "200":
          description: A candle_ref, plus an upload_url when the candles are not cached.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CandlesResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/PaidTierRequired" }

  /backtests:
    post:
      tags: [Backtests]
      operationId: runBacktest
      summary: Run a single backtest (Lane 1, spot-market only).
      description: |
        Backtest one strategy over a date range and return summary statistics
        synchronously. Supply candles either by `candle_ref` (from `POST /v1/candles`)
        or inline in `candles` (up to 5000 bars) — exactly one of the two.

        Billing: 1 backtest = 1 call; 1,000 calls = 1 credit, reserved as a prepaid
        1,000-call block (so normal use of tens or hundreds of calls per session is
        effectively free). Per-key rate and daily-call limits apply.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BacktestRequest" }
      responses:
        "200":
          description: Backtest summary statistics.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BacktestResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/PaidTierRequired" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /scans:
    post:
      tags: [Scans]
      operationId: createScan
      summary: Create a scan (Lane 2 parameter sweep).
      description: |
        Submit a card x grid scan. The response returns the candle requirements the
        client must upload (each with the warmup-inclusive bar count) and, on a
        cache miss, a single `upload_url` for the combined candle file. After
        uploading, call `POST /v1/scans/{id}/start`. Billed by the size of the
        parameter grid, exactly like the web scanner. Re-running an identical scan
        is served from cache for free — pass `force_fresh: true` to force a paid
        recompute (needed when you want the full-population export).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScanRequest" }
      responses:
        "200":
          description: The created scan with candle requirements.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScanSubmitResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/PaidTierRequired" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }

  /scans/{id}/start:
    post:
      tags: [Scans]
      operationId: startScan
      summary: Start a created scan after candles are uploaded.
      parameters:
        - $ref: "#/components/parameters/ScanId"
      responses:
        "200":
          description: The scan is running (or already completed from cache).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScanStartResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /jobs/{id}:
    get:
      tags: [Jobs]
      operationId: getJob
      summary: Get a scan's status, top-K results, and population statistics.
      description: |
        Returns progress, and when complete the top-K leaderboard (each row with
        PSR, DSR, num_trials, and walk-forward fields) plus the population summary
        (combos_evaluated, num_trials, deflated Sharpe threshold). Free.
      parameters:
        - $ref: "#/components/parameters/ScanId"
      responses:
        "200":
          description: Job status and results.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobStatusResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /jobs/{id}/export:
    get:
      tags: [Jobs]
      operationId: getJobExport
      summary: Get the full-population parquet export for a scan.
      description: |
        `intent=probe` reports whether the export is ready; `intent=download`
        returns presigned URLs (valid 6 hours) for the parquet files of the full
        evaluated population, for local analysis in duckdb or pandas. Only available
        for scans run with `full_export: true` on a fresh (non-cached) computation.
        Free.
      parameters:
        - $ref: "#/components/parameters/ScanId"
        - name: intent
          in: query
          required: true
          schema:
            type: string
            enum: [probe, download]
      responses:
        "200":
          description: Probe result, or the download manifest.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/JobExportResponse" }
        "202":
          description: The export is still being prepared; retry.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "410":
          description: The scan completed but no export was produced (e.g. a cache hit). Re-run with force_fresh.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Send your API key as `Authorization: Bearer rk_live_<43 base62 chars>`.
        Create a key on your account page (rulyfi.com -> Account -> API keys); it
        requires a paid plan (Plus or higher) and is shown once.

  parameters:
    ScanId:
      name: id
      in: path
      required: true
      description: The scan_id returned by POST /v1/scans.
      schema:
        type: string

  # ── Enums (mirror publicApi.ts — verify-openapi asserts exact set equality) ──
  schemas:
    Exchange:
      type: string
      description: Market-data source (26 supported).
      enum:
        - binance
        - bybit
        - okx
        - bitget
        - gate
        - kucoin
        - coinbase
        - kraken
        - mexc
        - htx
        - bingx
        - cryptocom
        - bitstamp
        - bitfinex
        - gemini
        - binanceus
        - whitebit
        - lbank
        - phemex
        - bitvavo
        - poloniex
        - bitkub
        - backpack
        - hashkey
        - hyperliquid
        - dydx

    Timeframe:
      type: string
      enum: ["1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w"]

    Direction:
      type: string
      enum: [long, short]

    LogicOp:
      type: string
      enum: [AND, OR]

    OrderType:
      type: string
      enum: [market, limit]

    Operator:
      type: string
      description: Condition operator. crossover/crossunder need a previous bar.
      enum: [">", ">=", "<", "<=", "==", "!=", crossover, crossunder]

    PriceField:
      type: string
      enum: [open, high, low, close, volume]

    ScanMarketType:
      type: string
      description: Scan symbol market type. Default is USDT-margined perpetual futures.
      enum: [spot_usdt, spot_usdc, fut_usdt_m, fut_usdc_m]

    ApiErrorCode:
      type: string
      description: Stable error code in the common error envelope.
      enum:
        - invalid_request
        - strategy_invalid
        - paid_tier_required
        - rate_limited
        - daily_cap_exceeded
        - insufficient_credits
        - candle_ref_not_found
        - candles_invalid
        - range_invalid
        - not_found
        - method_not_allowed
        - internal_error
        - unavailable

    # ── Error envelope ──
    ApiError:
      type: object
      description: Common error envelope (plan §4.3). No stack traces or internal names.
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { $ref: "#/components/schemas/ApiErrorCode" }
            message: { type: string }
            hint:
              type: string
              description: Actionable guidance. On 429 this nudges you toward run_scan.

    ValidationError:
      type: object
      required: [path, message]
      properties:
        path:
          type: string
          description: Dotted field path, e.g. "indicators[0].params.period".
        message: { type: string }
        hint:
          type: string
          description: A correct example for this field.

    # ── Strategy object (Lane 1 input) ──
    Condition:
      type: object
      required: [left, op, right]
      properties:
        left: { $ref: "#/components/schemas/Operand" }
        op: { $ref: "#/components/schemas/Operator" }
        right: { $ref: "#/components/schemas/Operand" }
      additionalProperties: false

    Operand:
      description: |
        A number, a numeric string, a price field (open/high/low/close/volume), or
        a dotted indicator reference "<indicatorId>.<output>" (e.g. "rsi1.value").
      oneOf:
        - type: number
        - type: string

    Indicator:
      type: object
      required: [id, type]
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 64
          description: Unique handle referenced by conditions as "<id>.<output>".
        type:
          type: string
          minLength: 1
          maxLength: 64
          description: One of the 103 catalog types (GET /v1/catalog/indicators).
        params:
          type: object
          additionalProperties: { type: number }
          default: {}
      additionalProperties: false

    EntryRules:
      type: object
      properties:
        logic: { $ref: "#/components/schemas/LogicOp" }
        conditions:
          type: array
          maxItems: 10
          items: { $ref: "#/components/schemas/Condition" }
          default: []
      additionalProperties: false

    ExitRules:
      type: object
      properties:
        take_profit:
          type: [number, "null"]
          exclusiveMinimum: 0
          maximum: 100
          default: null
          description: Close at this percent gain. null disables.
        stop_loss:
          type: [number, "null"]
          exclusiveMinimum: 0
          maximum: 100
          default: null
          description: Close at this percent loss (magnitude). null disables.
        ttl_bars:
          type: [integer, "null"]
          exclusiveMinimum: 0
          maximum: 10000
          default: null
          description: Maximum bars to hold one trade. null disables.
        logic: { $ref: "#/components/schemas/LogicOp" }
        conditions:
          type: array
          maxItems: 10
          items: { $ref: "#/components/schemas/Condition" }
          default: []
        auto_enabled:
          type: boolean
          default: true
          description: Master switch for take_profit/stop_loss/ttl_bars.
        cond_enabled:
          type: boolean
          default: true
          description: Master switch for condition-based exits.
        conservative_fill:
          type: boolean
          default: false
          description: When TP and SL are both reachable in one bar, false assumes TP filled first, true assumes SL.
      additionalProperties: false

    Position:
      type: object
      properties:
        leverage:
          type: number
          exclusiveMinimum: 0
          maximum: 125
          default: 1
        size_pct:
          type: number
          minimum: 0.1
          maximum: 100
          default: 100
        max_concurrent:
          type: integer
          minimum: 1
          default: 1
      additionalProperties: false

    Costs:
      type: object
      description: Fees and slippage in percent notation.
      properties:
        commission_maker_pct: { type: number, minimum: 0, maximum: 100, default: 0.02 }
        commission_taker_pct: { type: number, minimum: 0, maximum: 100, default: 0.04 }
        slippage_pct: { type: number, minimum: 0, maximum: 100, default: 0.01 }
        order_type: { $ref: "#/components/schemas/OrderType" }
      additionalProperties: false

    Strategy:
      type: object
      description: |
        Public strategy object (Lane 1 input). Same shape as
        https://rulyfi.com/strategy-schema.md. The backtest period (range) is
        supplied at run time, not in the strategy object.
      required: [symbol, timeframe, direction]
      properties:
        symbol: { type: string, minLength: 1, maxLength: 32 }
        exchange: { $ref: "#/components/schemas/Exchange" }
        timeframe: { $ref: "#/components/schemas/Timeframe" }
        direction: { $ref: "#/components/schemas/Direction" }
        indicators:
          type: array
          maxItems: 20
          items: { $ref: "#/components/schemas/Indicator" }
          default: []
        entry_rules: { $ref: "#/components/schemas/EntryRules" }
        exit_rules: { $ref: "#/components/schemas/ExitRules" }
        position: { $ref: "#/components/schemas/Position" }
        costs: { $ref: "#/components/schemas/Costs" }
      additionalProperties: false

    # ── Candles ──
    Range:
      type: object
      description: UTC-millisecond backtest range.
      required: [start_ts, end_ts]
      properties:
        start_ts: { type: integer, minimum: 0 }
        end_ts: { type: integer, exclusiveMinimum: 0 }

    InlineCandle:
      description: A candle as [t,o,h,l,c,v] or {t,o,h,l,c,v}.
      oneOf:
        - type: array
          items: { type: number }
          minItems: 6
          maxItems: 6
        - type: object
          required: [t, o, h, l, c, v]
          properties:
            t: { type: number }
            o: { type: number }
            h: { type: number }
            l: { type: number }
            c: { type: number }
            v: { type: number }

    CandlesRequest:
      type: object
      required: [exchange, symbol, timeframe, start_ts, end_ts]
      properties:
        exchange: { $ref: "#/components/schemas/Exchange" }
        symbol: { type: string, minLength: 1, maxLength: 32 }
        timeframe: { $ref: "#/components/schemas/Timeframe" }
        start_ts: { type: integer, minimum: 0 }
        end_ts: { type: integer, exclusiveMinimum: 0 }
      additionalProperties: false

    CandlesResponse:
      type: object
      required: [candle_ref, cached]
      properties:
        candle_ref: { type: string }
        cached:
          type: boolean
          description: True when the candles are already cached; no upload needed.
        upload_url:
          type: string
          description: Present only on a cache miss — PUT the candle wire here.
        wire_format:
          type: string
          description: The upload encoding (constant).
          enum: [v4-gzip]

    # ── Validate ──
    ValidateStrategyRequest:
      type: object
      required: [strategy]
      properties:
        strategy: { $ref: "#/components/schemas/Strategy" }
      additionalProperties: false

    ValidateScanRequest:
      type: object
      required: [scan]
      properties:
        scan: { $ref: "#/components/schemas/ScanRequest" }
      additionalProperties: false

    ValidateResult:
      type: object
      required: [valid, errors, estimated_credits]
      properties:
        valid: { type: boolean }
        errors:
          type: array
          items: { $ref: "#/components/schemas/ValidationError" }
        normalized:
          $ref: "#/components/schemas/Strategy"
        estimated_credits:
          type: object
          description: "Lane 1 is call-based: 1 backtest = 1 call, 1,000 calls = 1 credit."
          properties:
            per_backtest_calls: { type: integer }
            calls_per_credit: { type: integer }

    # ── Backtest ──
    BacktestRequest:
      type: object
      description: Supply exactly one of candle_ref or candles.
      required: [strategy]
      properties:
        strategy: { $ref: "#/components/schemas/Strategy" }
        candle_ref:
          type: string
          minLength: 1
          maxLength: 256
        candles:
          type: array
          maxItems: 5000
          items: { $ref: "#/components/schemas/InlineCandle" }
        range: { $ref: "#/components/schemas/Range" }
        walk_forward: { type: boolean, default: false }
        wf_folds: { type: integer, minimum: 2, maximum: 5, default: 3 }
        include_trades: { type: boolean, default: false }
      additionalProperties: false

    BacktestResult:
      type: object
      description: Summary statistics for a single backtest (plan D11).
      properties:
        metrics:
          type: object
          description: Return %, win rate, Sharpe, max drawdown, profit factor, exit breakdown, trade count.
        psr:
          type: [number, "null"]
          description: Probabilistic Sharpe Ratio.
        trades_summary:
          type: object
          description: Aggregate trade summary (and up to 50 recent trades when include_trades is true).
        wf:
          type: object
          description: Present when walk_forward is true — per-fold in-sample/out-of-sample summaries.
        engine_version: { type: string }
        credits:
          type: object
          properties:
            block_calls_used: { type: integer }
            credits_charged_today: { type: integer }

    # ── Scan (Lane 2) ──
    ScanParamChip:
      description: A scalar value or a range sweep {from,to,step}.
      oneOf:
        - type: number
        - type: object
          required: [from, to, step]
          properties:
            from: { type: number }
            to: { type: number }
            step: { type: number }
          additionalProperties: false

    ScanCard:
      type: object
      required: [type, timeframe]
      properties:
        type: { type: string, minLength: 1, maxLength: 64 }
        timeframe: { $ref: "#/components/schemas/Timeframe" }
        params:
          type: object
          description: "Per-parameter chip arrays, e.g. { period: [14, 20] } or { period: [{from:10,to:50,step:5}] }."
          additionalProperties:
            type: array
            maxItems: 1000
            items: { $ref: "#/components/schemas/ScanParamChip" }
          default: {}
      additionalProperties: false

    ScanExit:
      type: object
      required: [tps, sls, ttls]
      properties:
        tps:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: "#/components/schemas/ScanParamChip" }
        sls:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: "#/components/schemas/ScanParamChip" }
        ttls:
          type: array
          minItems: 1
          maxItems: 1000
          items: { $ref: "#/components/schemas/ScanParamChip" }
      additionalProperties: false

    ScanRange:
      type: object
      required: [start_ts, end_ts]
      properties:
        start_ts: { type: integer, exclusiveMinimum: 0 }
        end_ts: { type: integer, exclusiveMinimum: 0 }

    ScanRequest:
      type: object
      required: [cards, exit, combo_size, symbols, range, direction]
      properties:
        cards:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: "#/components/schemas/ScanCard" }
        exit: { $ref: "#/components/schemas/ScanExit" }
        combo_size:
          type: integer
          minimum: 2
          maximum: 5
          description: k=1 is not offered — the scanner is a combo product.
        symbols:
          type: array
          minItems: 1
          maxItems: 50
          items: { type: string, minLength: 1, maxLength: 32 }
        exchange: { $ref: "#/components/schemas/Exchange" }
        market_type: { $ref: "#/components/schemas/ScanMarketType" }
        range: { $ref: "#/components/schemas/ScanRange" }
        direction: { $ref: "#/components/schemas/Direction" }
        top_k: { type: integer, minimum: 1, maximum: 100, default: 100 }
        min_trade_count: { type: integer, minimum: 0, maximum: 100000, default: 0 }
        walk_forward: { type: boolean, default: false }
        wf_folds: { type: integer, minimum: 2, maximum: 5, default: 3 }
        full_export: { type: boolean, default: false }
        force_fresh:
          type: boolean
          default: false
          description: |
            Skip the cache and force a paid recompute. Needed to produce a
            full-population parquet export when an identical scan is already cached
            (a cache hit produces no export). Re-charges credits.
      additionalProperties: false

    ScanCandleRequirement:
      type: object
      properties:
        exchange: { type: string }
        symbol: { type: string }
        market_type: { type: string }
        timeframe: { $ref: "#/components/schemas/Timeframe" }
        start_ts: { type: integer }
        end_ts: { type: integer }
        bars:
          type: integer
          description: Warmup-inclusive bar count to fetch and upload.

    ScanSubmitResponse:
      type: object
      required: [scan_id, estimated_credits, candle_requirements, cached]
      properties:
        scan_id: { type: string }
        estimated_credits: { type: integer }
        candle_requirements:
          type: array
          items: { $ref: "#/components/schemas/ScanCandleRequirement" }
        upload_url:
          type: string
          description: Present only on a cache miss — the combined candle file PUT target.
        cached: { type: boolean }
        export_available:
          type: boolean
          description: Present when full_export was requested. False on a cache hit (no parquet produced).
        export_unavailable_reason:
          type: string
          enum: [cache_hit]
        hint: { type: string }

    ScanStartResponse:
      type: object
      required: [scan_id, status]
      properties:
        scan_id: { type: string }
        status:
          type: string
          enum: [running, completed]

    JobResultRow:
      type: object
      description: One leaderboard row (internal fields stripped, snake_case).
      properties:
        rank: { type: integer }
        direction: { $ref: "#/components/schemas/Direction" }
        total_return: { type: number }
        sharpe: { type: [number, "null"] }
        mdd: { type: number }
        win_rate: { type: number }
        trade_count: { type: integer }
        payoff: { type: [number, "null"] }
        psr: { type: [number, "null"] }
        dsr: { type: [number, "null"] }
        t_stat: { type: [number, "null"] }
        wf_efficiency: { type: [number, "null"] }
        forward_neg_folds: { type: [integer, "null"] }
        name: { type: string }
        bars: { type: integer }
        entry_conditions_text:
          type: array
          items: { type: string }

    JobStatusResponse:
      type: object
      required: [scan_id, status, progress]
      properties:
        scan_id: { type: string }
        status: { type: string }
        progress:
          type: object
          properties:
            chunks_total: { type: integer }
            chunks_done: { type: integer }
            combos_evaluated: { type: integer }
            total_strategies: { type: integer }
        results:
          type: array
          items: { $ref: "#/components/schemas/JobResultRow" }
        population:
          type: object
          properties:
            combos_evaluated: { type: integer }
            num_trials: { type: [integer, "null"] }
            deflated_sharpe_threshold: { type: [number, "null"] }
            pbo: { type: [number, "null"] }
            pbo_blocks: { type: [integer, "null"] }
            pbo_strategies: { type: [integer, "null"] }
        error: { type: [string, "null"] }

    JobExportFile:
      type: object
      properties:
        url:
          type: string
          description: Presigned GET URL, valid 6 hours.
        bytes: { type: integer }
        rows: { type: integer }

    JobExportResponse:
      type: object
      description: Probe result (intent=probe) or download manifest (intent=download).
      properties:
        ready: { type: boolean }
        files:
          type: array
          items: { $ref: "#/components/schemas/JobExportFile" }
        merged: { type: boolean }

    IndicatorCatalogResponse:
      type: object
      properties:
        indicators:
          type: array
          description: 103 indicator entries.
          items:
            type: object
            properties:
              type: { type: string }
              name: { type: string }
              category: { type: string }
              description: { type: string }
              params:
                type: array
                items:
                  type: object
                  properties:
                    key: { type: string }
                    default: { type: number }
                    step: { type: number }
              outputs:
                type: array
                items: { type: string }
        operators:
          type: array
          items: { $ref: "#/components/schemas/Operator" }
        price_fields:
          type: array
          items: { $ref: "#/components/schemas/PriceField" }

    SymbolCatalogResponse:
      type: object
      properties:
        exchanges:
          type: array
          items: { $ref: "#/components/schemas/Exchange" }
        timeframes:
          type: array
          items: { $ref: "#/components/schemas/Timeframe" }
        directions:
          type: array
          items: { $ref: "#/components/schemas/Direction" }

  responses:
    Unauthorized:
      description: Missing, invalid, or revoked API key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    BadRequest:
      description: Malformed request or invalid strategy/scan/candles/range.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    PaidTierRequired:
      description: The account is on the Free tier. A paid plan (Plus or higher) is required.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    InsufficientCredits:
      description: Not enough credits to reserve the next 1,000-call block (or the scan grid).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    RateLimited:
      description: Per-key rate (60 calls/min) or daily call cap exceeded. The hint nudges you toward run_scan.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    NotFound:
      description: The scan job or candle_ref was not found (or is not yours).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
