# 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://7dzx8o0jkb.execute-api.ap-northeast-1.amazonaws.com/Prod/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.

        For `{ "scan": ... }` this runs the same checks the submit path runs, minus
        account state: schema, the candle window (`end_ts` must be at or before the
        latest closed candle of **every** card timeframe — the same `end_ts` can pass
        on 1h and fail on 4h — and the physical snapshot size), your plan's limits
        (symbols, cards, combo_size, direction, timeframes, bars), and the credit cost.
        Credit caps, per-key daily caps, and purchased balance are evaluated only at
        submission, so a `valid: true` scan can still be rejected by POST /v1/scans for
        insufficient credits.
      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 }
        funding:
          type: boolean
          default: false
          description: Separates candle cache entries that include Binance perpetual funding data.
      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:
          description: The normalized input — a Strategy for `{strategy}`, a ScanRequest for `{scan}`. Null when the input failed schema validation.
          oneOf:
            - $ref: "#/components/schemas/Strategy"
            - $ref: "#/components/schemas/ScanRequest"
          nullable: true
        estimated_credits:
          description: |
            For `{strategy}` (Lane 1, call-based): an object — 1 backtest = 1 call, 1,000 calls = 1 credit.
            For `{scan}` (Lane 2): an integer — the credits this scan would reserve at submission
            (same formula as POST /v1/scans), or null when invalid.
          oneOf:
            - type: object
              properties:
                per_backtest_calls: { type: integer }
                calls_per_credit: { type: integer }
            - type: integer
          nullable: true
        estimated_backtests:
          type: integer
          description: Scan validation only — total backtests (cells) the scan would evaluate.
        note:
          type: string
          description: Scan validation only — reminds that plan limits are enforced at submission.

    # ── 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" }
        funding:
          type: boolean
          default: false
          description: Apply Binance perpetual funding rates as a cost model (all symbols). Requires funding data in the uploaded candle wire; inline candles cannot be used with funding.
        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
          description: |
            Indicator type. Must be one of the 103 catalog types — an unknown type is rejected
            with 400 and the error suggests the nearest match, because the scan engine cannot
            compute an indicator it does not know. Every catalog type currently has
            `supports_scan: true`; read that flag rather than assuming it, since a type with no
            scan signal preset on any path is also rejected with 400 (the card would add cost
            without adding a condition) and the error names the alternative to use.
        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: {}
        preset:
          type: string
          enum: [auto, cross, squeeze]
          default: auto
          description: |
            Signal preset.
              * `auto` (default) — threshold/state presets. For the volatility indicators this is the
                *expansion* filter: `ratio > 1.2` (ATR, NATR, HISTORICAL_VOLATILITY, PARKINSON_VOL,
                YANG_ZHANG) or `ratio > 1.3` (ULCER_INDEX, BB_WIDTH), where
                `ratio = value / SMA(value, signal_period)`. The ratio is dimensionless, so one
                threshold transfers across symbols and timeframes; an absolute threshold on the raw
                value does not.
              * `cross` — line-cross signals (long = fast line crosses above slow, short = crosses
                below), which makes the slow-line parameters (`d_period`, `signal_period`, ...) live
                sweep axes. Available for STOCHASTIC_RSI, STOCHASTIC, TRIX, TSI, MACD, PPO, PVO,
                KLINGER, OBV only; other types are rejected. **OBV requires it** — a cumulative
                series starts from an arbitrary origin, so only the moving-average cross carries
                information, and `preset: "auto"` on an OBV card is rejected.
              * `squeeze` — volatility *contraction*: `ratio < 0.8` (NATR), `< 0.65` (BB_WIDTH),
                `< 0.4` (ULCER_INDEX), or `value < -21` (CHAIKIN_VOLATILITY). Available for those
                four types only. ATR, HISTORICAL_VOLATILITY, PARKINSON_VOL and YANG_ZHANG support
                expansion but not contraction: their low-side thresholds do not transfer across
                symbols, so no squeeze preset is defined for them.
            CHAIKIN_VOLATILITY is compared on its absolute value (`> 24` / `< -21`), not a ratio,
            because its formula is already a rate of change.
      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 }
          description: |
            Perpetual-futures symbols use the web scanner's ".P" suffix convention
            (e.g. "BTCUSDT.P" with market_type "fut_usdt_m"); spot symbols are plain
            (e.g. "BTCUSDT"). Matching the web convention keeps candle caches and
            scan identities aligned with scans run from the web app.
        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 }
        funding:
          type: boolean
          default: false
          description: Apply Binance perpetual funding rates as a cost model (all symbols).
        conservative_fill:
          type: boolean
          default: false
          description: |
            Resolve a bar where TP and SL are both hit. False assumes TP filled
            first (optimistic); true assumes SL filled first (conservative).
        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"] }
        rmp:
          type: [number, "null"]
          description: >-
            Random-Max Percentile (0-1, exploratory): probability that the best result pure chance
            would produce across this scan's num_trials i.i.d. trials falls below this row
            (Phi(q)^N with q = t_stat over the PSR denominator). Unlike DSR it stays informative
            at high trade counts. Present on results computed by engine >= 1.15.0.
        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 }
              supports_scan:
                type: boolean
                description: |
                  Whether this indicator can be used as a card in POST /v1/scans. All 103
                  indicators currently support it. The flag stays in the response because an
                  indicator with no scan signal preset on any path cannot have an entry condition
                  derived from it: such a type is rejected with 400 as a scan card, and the error
                  names the alternative indicator to use. Read this flag rather than assuming it
                  is always true.
              supports_cross_preset:
                type: boolean
                description: >
                  Whether this indicator accepts `preset: "cross"` on a scan card (it has a
                  fast/slow line pair). For OBV this preset is not optional but required — see the
                  ScanCard `preset` description.
              supports_squeeze_preset:
                type: boolean
                description: >
                  Whether this indicator accepts `preset: "squeeze"` on a scan card (the
                  volatility-contraction variant of its expansion preset). True for four types;
                  every other type is rejected with 400, because a squeeze condition it does not
                  have would drop out silently and leave you paying for a card that filters
                  nothing.
        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" }
