openapi: 3.1.0
info:
  title: AtlasYield API
  version: "1.0"
  description: >-
    Read-only access to the Atlas Score Index: neutral 16-factor vault scores across four
    pillars — Yield, Safety, Liquidity, Sustainability — plus the tracked vault catalog
    and the Atlas Engine, which turns those scores into a risk-adjusted allocation.
    Research and information, not investment advice.

    Every endpoint in this spec is public and keyless, limited to 60 requests per minute per
    IP. An optional partner key sent in the `x-api-key` header moves the caller to its own
    bucket of 100 requests per minute and unlocks two endpoints that are not part of this
    spec (every vault's factor breakdown in one call, and score history). A key is never
    required: an unknown or retired key sent to a public endpoint is ignored, and only the
    two partner endpoints reject it, with a 401. How to get one:
    https://docs.atlasyield.club/partner-access
servers:
  - url: https://api.atlasyield.club/v1
security:
  - {}
  - PartnerKey: []
components:
  securitySchemes:
    PartnerKey:
      type: apiKey
      in: header
      name: x-api-key
      description: >-
        Optional. Not needed for any endpoint in this spec. Unlocks the partner-only
        endpoints and a 100 requests per minute bucket (public limit is 60 per IP).
  schemas:
    CompactScore:
      type: object
      description: >-
        Returned by GET /scores when fields=compact. About 410 bytes per row (a full Score is
        about 820). No pillar, factor-quality or admin blobs. Fetch /vaults/{chainId}/{address}/factors
        for the breakdown.
      properties:
        vaultId: { type: string, example: "morpho:8453:0xbbb2" }
        chainId: { type: integer, example: 8453 }
        protocolId: { type: string, example: morpho }
        name: { type: [string, "null"] }
        asset: { type: [string, "null"], example: USDC }
        composite: { type: number, example: 80 }
        label: { type: string, example: Good }
        apy: { type: [number, "null"], description: "Live APY in percentage points." }
        tvl: { type: [number, "null"], description: "Live TVL in USD." }
        dataQuality: { type: string, enum: [sufficient, partial, fallback] }
        routable:
          type: boolean
          description: >-
            True when the vault is in the live catalog and not flagged unroutable by the route screen.
            A vault missing from the live catalog is false (fail closed).
        scoredAt: { type: string, format: date-time }
        pageUrl:
          type: string
          description: Citable vault page.
          example: "https://atlasyield.club/vaults/morpho/8453/0xbbb2"
    Score:
      type: object
      properties:
        vaultId:
          type: string
          description: "Stable identifier: protocol:chainId:address."
          example: "aave-v3:8453:0x4e65fe4dba92790696d040ac24aa414708f5c0ab"
        chainId:
          type: integer
          description: >-
            Chain id. EVM chains use their EIP-155 id (1 Ethereum, 8453 Base, 42161 Arbitrum);
            Solana is 792703809 (Relay's id).
          example: 8453
        protocolId:
          type: string
          description: >-
            Adapter/protocol slug. Currently one of beefy, yearn-v3, morpho, pendle,
            aave-v3, euler, etherfi, ethena, kamino. New adapters are added over time — treat
            this as an open set rather than a fixed enum.
          example: aave-v3
        name:
          type: string
          nullable: true
          description: >-
            Human-readable vault name, the same string /catalog reports, so no join is
            needed. Null only when the vault is absent from the live catalog cache.
          example: "Aave V3 USDC"
        onchainId:
          type: string
          nullable: true
          description: >-
            The vault address with its original case. vaultId is lowercased, which is
            lossless for 0x addresses but not for Solana base58, so use this field to
            look a vault up on-chain. Null only when the vault is absent from the live cache.
          example: "0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB"
        adminTopology:
          type: object
          nullable: true
          description: >-
            AY-269: per-vault admin setup, read daily. Null when the vault is not screened
            (Aave, Pendle, EtherFi, Ethena, and BNB vaults) and scores on protocol metadata
            instead. `class` is one of immutable, timelock_long_multisig_strong (7+ day
            delay, a 3+ threshold multisig that is also a majority of signers),
            timelock_short_multisig_weak, multisig_only, eoa (a single admin key), or
            unknown. For an `eoa` vault, `curatorRating` decides the score directly: 40 when
            the curator is rated established, 10 when unknown or new; every class floors at
            10. `roles` never carries an address, only kind (none, eoa, safe, dao,
            unrecognised) plus threshold/owners for a safe. `dao` is on-chain governance
            (a token-vote governor or Sky), scored like a strong multisig; a dao with
            `unverifiedGovernance` (Aragon, plugin not inspected) is scored like a weak one.
            `delaySeconds` is the delay a timelock or supervisor adds in front of that role,
            on top of the vault's own `timelockSeconds`.
          properties:
            class:
              type: string
              enum: [immutable, timelock_long_multisig_strong, timelock_short_multisig_weak, multisig_only, eoa, unknown]
            curatorId:
              type: string
              nullable: true
            curatorRating:
              type: string
              nullable: true
              enum: [established, unknown_new]
            timelockSeconds:
              type: number
              nullable: true
            roles:
              type: array
              items:
                type: object
                properties:
                  role: { type: string }
                  kind: { type: string, enum: [none, eoa, safe, dao, unrecognised] }
                  threshold: { type: number }
                  owners: { type: number }
                  delaySeconds: { type: number }
                  unverifiedGovernance: { type: boolean }
            checkedAt:
              type: string
              format: date-time
        asset:
          type: string
          description: Underlying asset symbol.
          example: USDC
        assetClass:
          type: string
          nullable: true
          enum: [stablecoin, stablecoin-yield, eth, btc, top50, usd-exotic, other]
          description: Asset class bucket used to contextualise the yield factors. May be null.
          example: stablecoin
        composite:
          type: number
          description: 0–100 composite Atlas Score.
          example: 87.5
        label:
          type: string
          enum: [Conservative, Balanced, Aggressive]
          description: Risk-posture band for the composite.
        pillarScores:
          type: object
          description: "The four pillar scores, 0–100 each."
          properties:
            yield: { type: number, example: 82.0 }
            safety: { type: number, example: 90.0 }
            liquidity: { type: number, example: 88.0 }
            sustainability: { type: number, example: 85.0 }
        apy:
          type: number
          nullable: true
          description: Current APY in percentage points (5.21 = 5.21%). May be null.
          example: 5.21
        tvl:
          type: number
          nullable: true
          description: Total value locked, USD. May be null.
          example: 1250400
        dataQuality:
          type: string
          enum: [sufficient, partial, fallback]
          description: How complete the inputs were.
        scoredAt:
          type: string
          format: date-time
          description: ISO-8601 UTC timestamp the score was computed (ex-ante, immutable).
        scorerVersion:
          type: string
          description: Scoring engine version that produced the row.
          example: "2.2.0"
    CatalogEntry:
      type: object
      properties:
        id:
          type: string
          description: Vault identifier (same as vaultId in /scores).
          example: "aave-v3:8453:0x4e65fe4dba92790696d040ac24aa414708f5c0ab"
        chainId:
          type: integer
          example: 8453
        protocolId:
          type: string
          example: aave-v3
        protocol:
          type: string
          description: Human-readable protocol name.
          example: Aave V3
        asset:
          type: string
          example: USDC
        name:
          type: string
          description: Display name (protocol + asset).
          example: Aave V3 USDC
        tvl:
          type: number
          description: Total value locked, USD.
          example: 1250400
        apy:
          type: number
          description: Current APY in percentage points.
          example: 5.21
    PortfolioSlice:
      type: object
      description: One position in the proposed allocation.
      properties:
        vaultId:
          type: string
          description: Vault identifier (same as vaultId in /scores).
          example: "morpho:1:0xbeefc1cdafc5b4a649b54d07afc6bf0f75c6f4e2"
        protocol:
          type: string
          example: morpho
        chainId:
          type: integer
          example: 1
        asset:
          type: string
          description: Underlying asset symbol.
          example: USDC
        name:
          type: string
          description: Vault display name.
          example: STEAKUSDTBETHENA
        apy:
          type: number
          description: Current APY in percentage points.
          example: 12.18706
        tvl:
          type: number
          description: Total value locked, USD.
          example: 11780173
        composite:
          type: number
          description: The vault's 0–100 Atlas Score at allocation time.
          example: 78.762
        fraction:
          type: number
          description: Share of principal as a fraction, rounded to 1e-4.
          example: 0.15
        allocationPct:
          type: number
          description: Share of principal as a percentage, 2 dp.
          example: 15
        allocationUsd:
          type: number
          description: Dollar amount allocated to this vault.
          example: 1500
        vaultAddress:
          type: string
          description: On-chain vault contract address.
          example: "0xbeefc1cdafc5b4a649b54d07afc6bf0f75c6f4e2"
        underlyingToken:
          type: string
          description: Address of the token the vault accepts.
          example: "0xc139190f447e929f090edeb554d95abb8b18ac1c"
    FactorResult:
      type: object
      properties:
        score: { type: number, description: "Sub-score 0–100. For EXPLOIT_HISTORY this is multiplier × 100.", example: 82 }
        weight: { type: number, description: "Weight inside its pillar's arithmetic mean. 0 for EXPLOIT_HISTORY (multiplicative).", example: 0.3 }
        rawValue: { type: number, description: "The raw input that drove the score (TVL USD, audit count, slope, ...).", example: 5.2 }
        label: { type: string, description: "Plain-English description of the input.", example: "5.2% APY, stablecoin" }
        pillar: { type: string, enum: [yield, safety, liquidity, sustainability] }
    VaultFactors:
      type: object
      properties:
        vaultId: { type: string, example: "aave-v3:8453:0x4e65fe4dba92790696d040ac24aa414708f5c0ab" }
        chainId: { type: integer, example: 8453 }
        protocolId: { type: string, example: aave-v3 }
        composite: { type: number, example: 87.5 }
        label: { type: string, enum: [Conservative, Balanced, Aggressive] }
        pillarScores:
          type: object
          properties:
            yield: { type: number }
            safety: { type: number }
            liquidity: { type: number }
            sustainability: { type: number }
        factors:
          type: object
          description: >-
            All 16 factors keyed by name: YIELD_LEVEL, APY_STABILITY, APY_PERSISTENCE,
            YIELD_SOURCE, AUDIT_QUALITY, PROTOCOL_MATURITY, TVL_DEPTH_SAFETY, TVL_STABILITY,
            ADMIN_TOPOLOGY, CHAIN_SECURITY, EXPLOIT_HISTORY, WITHDRAWAL_TYPE,
            TVL_DEPTH_LIQUIDITY, EMISSION_DEPENDENCY, ASSET_RISK, UTILIZATION_HEALTH.
          additionalProperties:
            $ref: "#/components/schemas/FactorResult"
        dataQuality: { type: string, enum: [sufficient, partial, fallback] }
        scoredAt: { type: string, format: date-time }
        scorerVersion: { type: string, example: "2.2.0" }
    VaultMetrics:
      type: object
      description: >-
        Tier 1 live metrics for one vault. Separate from the Atlas Score, never folded
        into the composite.
      properties:
        vaultId:
          type: string
          description: "Stable identifier: protocol:chainId:address."
          example: "aave-v3:8453:0x4e65fe4dba92790696d040ac24aa414708f5c0ab"
        chainId:
          type: integer
          example: 8453
        yieldQuality:
          type: number
          nullable: true
          description: >-
            0-100. How much of the headline APY comes from the vault's own economics
            rather than transient incentives. Null when the APY split is unavailable.
          example: 82
        yieldQualityReason:
          type: string
          nullable: true
          description: Why that value was assigned, or why it is null.
        exitSafety:
          type: number
          nullable: true
          description: >-
            0-100. How safely a position can be exited at size. Null when the underlying
            liquidity data is unavailable.
          example: 74
        exitSafetyReason:
          type: string
          nullable: true
          description: Why that value was assigned, or why it is null.
        scorerRunDate:
          type: string
          format: date
          description: The day (UTC) these metrics were computed.
          example: "2026-09-04"
    VaultRoute:
      type: object
      description: >-
        Round-trip route-survival verdict for one vault: send USDC in, take the
        position, sell it straight back, on the exact path the app itself executes.
        "Can I get out at size, measured today" — not a liquidity claim, a real quote.
        SAME-CHAIN ONLY: funding USDC sits on the vault's own chain. Nothing here
        describes bridging in from another chain (see `probe`).
      properties:
        vaultId:
          type: string
          nullable: true
          description: "Stable identifier: protocol:chainId:address. Null when unscreened."
          example: "pendle:1:0x5949c3bfdf9babd8bf011d9d8dbb1a2c3e10d8b8"
        chainId:
          type: integer
          example: 1
        screened:
          type: boolean
          description: False when this vault has never been screened — no verdict is fabricated.
          example: true
        verdict:
          type: string
          nullable: true
          enum: [OK, DEGRADED, DANGEROUS, UNPRICEABLE, NO_ROUTE, SKIPPED, null]
          description: >-
            OK/DEGRADED are tradable. DANGEROUS, UNPRICEABLE and NO_ROUTE block deposits
            in the live engine. SKIPPED means our own probe was rate-limited, not a
            statement about the vault. Null when unscreened.
          example: OK
        reasonClass:
          type: string
          nullable: true
          enum: [protocol_refused, no_aggregator_quote, dust, no_amount, rate_limited, unmapped, null]
          description: >-
            Why a non-OK verdict was reached. `protocol_refused`: the protocol's own
            router (Pendle Convert) refuses the position — the vault is dead for entry
            or exit. `no_aggregator_quote`: no same-chain aggregator quotes USDC into
            the underlying (typical for LP-token vaults); the vault may still be
            exitable by redeeming and unwinding by hand. `dust`: value came back but
            below the 85% floor. Null for OK/DEGRADED.
          example: protocol_refused
        usdIn:
          type: number
          nullable: true
          description: USDC sent on the worst probed notional.
          example: 5000
        usdOut:
          type: number
          nullable: true
          description: USDC that came back after entering and exiting the position.
          example: 4991
        retained:
          type: number
          nullable: true
          description: usdOut / usdIn on the worst probed notional.
          example: 0.9982
        path:
          type: string
          nullable: true
          enum: [pendle, composer, native, null]
          description: Which execution path was probed — the same one the app itself calls.
          example: pendle
        probe:
          type: object
          description: >-
            What was measured, stated on every response. `scope` is always
            `same-chain`; `crossChain` is always `not probed`.
          properties:
            scope: { type: string, example: same-chain }
            funding: { type: string }
            legs: { type: string }
            notionalsUsd: { type: array, items: { type: number }, example: [100, 5000] }
            router: { type: object, additionalProperties: { type: string } }
            crossChain: { type: string, example: not probed }
        checkedAt:
          type: string
          format: date-time
          nullable: true
          description: When this verdict was last measured. Null when unscreened.
        cost:
          $ref: "#/components/schemas/RouteCost"
    RouteCost:
      type: object
      nullable: true
      description: >-
        Estimated round-trip execution cost at `notionalUsd`, same-chain, on the named
        router. fixedUsd = (vault entry + exit + swap + router approvals) gas × USD per
        gas; allInBps = fixedUsd / notional + proportional (router fee + price impact).
        An ESTIMATE at the stated gas basis: realised cost varies with gas at execution
        time. Null when the vault never completed a round trip. Unmeasured inputs are
        listed in `missing`; where a component is known to be missing, the figure is
        marked `floor: true` (a lower bound).
      properties:
        notionalUsd: { type: number, example: 10000 }
        router: { type: string, enum: [lifi, pendle-convert, none], description: "`none` = same token in and out, nothing swapped. Costs differ by router: the Atlas app deposits via Relay." }
        gasBasis: { type: string, enum: [p50_7d], description: Headline basis. Trailing 7-day median, so a single spike cannot set it. }
        fixedUsd:
          type: object
          properties:
            current: { type: number, nullable: true }
            p50_7d:  { type: number, nullable: true }
            p90_7d:  { type: number, nullable: true }
            floor:   { type: boolean, description: "True when a component is known to be missing (L1 data fee on L2s (Base, Optimism, Arbitrum), or router gas not reported): the true figure is higher." }
        gasUnits:
          type: object
          properties:
            swap:           { type: integer, nullable: true }
            vaultEntry:     { type: integer }
            vaultExit:      { type: integer }
            approvals:      { type: integer, description: "USDC->router on entry + underlying->router on exit. LiFi only; 0 for Pendle Convert or a same-token round trip." }
            total:          { type: integer }
            vaultGasSource: { type: string, enum: [observed, assumed], description: "`observed` = median of real transactions; `assumed` = no transaction exists yet." }
            highVariance:   { type: boolean, description: "True when the protocol's vault-action gas is a median of observed exits that disagreed by more than 2x." }
        proportionalBps:
          type: object
          properties:
            value:          { type: number, nullable: true }
            routerFeeBps:   { type: number, nullable: true }
            priceImpactBps: { type: number, nullable: true }
            extrapolated:   { type: boolean, description: "True outside the probed $100–$5,000: the nearest probe is held flat, not fitted." }
        allInBps:
          type: object
          properties:
            current: { type: number, nullable: true }
            p50_7d:  { type: number, nullable: true }
            p90_7d:  { type: number, nullable: true }
            floor:   { type: boolean, description: "Same as fixedUsd.floor. True means these figures are a LOWER BOUND; treat as not passed in any gate." }
        missing:
          type: array
          items: { type: string, enum: [swap_gas, l1_data_fee, gas_current, gas_history, no_route_at_size, gas_history_partial, high_variance_gas] }
    GasRegime:
      type: object
      properties:
        chainId: { type: integer, example: 1 }
        current:
          type: object
          nullable: true
          description: Newest 15-minute sample; null when older than 1 hour.
          properties:
            gwei:             { type: number }
            nativeUsd:        { type: number }
            usdPerMillionGas: { type: number }
            at:               { type: string, format: date-time }
        usdPerMillionGas:
          type: object
          description: Trailing 7-day percentiles; null until 6 hours of samples exist.
          properties:
            p10_7d: { type: number, nullable: true }
            p50_7d: { type: number, nullable: true }
            p90_7d: { type: number, nullable: true }
        samples:       { type: integer }
        coverageHours: { type: number }
  responses:
    ValidationError:
      description: >-
        A query parameter is missing or out of range. The body carries per-field
        detail under `details.fieldErrors`.
      content:
        application/json:
          schema:
            type: object
            properties:
              success: { type: boolean, example: false }
              error: { type: string, example: "Validation error" }
              details:
                type: object
                description: Zod field errors, keyed by parameter name.
paths:
  /scores:
    get:
      operationId: getScores
      summary: Latest scores
      description: >-
        The most recent composite + pillar scores for every tracked vault — one row per
        vault, newest first.
      parameters:
        - name: chainId
          in: query
          required: false
          schema: { type: integer }
          description: Filter to one EVM chain id.
          example: 8453
        - name: protocolId
          in: query
          required: false
          schema: { type: string }
          description: Filter to one protocol slug.
          example: morpho
        - name: address
          in: query
          required: false
          schema: { type: string }
          description: One vault by on-chain address (last segment of vaultId, case-insensitive). Returns at most one row per chain.
          example: "0xbeeF010f9cb27031ad51e3333f9aF9C6B1228183"
        - name: asset
          in: query
          required: false
          schema: { type: string }
          description: Underlying asset symbol, case-insensitive exact match.
          example: USDC
        - name: minScore
          in: query
          required: false
          schema: { type: number, minimum: 0, maximum: 100 }
          description: Keep vaults with composite >= this value.
        - name: minTvl
          in: query
          required: false
          schema: { type: number, minimum: 0 }
          description: Keep vaults whose LIVE TVL (USD) is >= this value.
        - name: routable
          in: query
          required: false
          schema: { type: string, enum: ["true"] }
          description: >-
            Only vaults a deposit can reach. Excludes vaults flagged unroutable and, failing closed,
            vaults missing from the live catalog. Only the value true is accepted.
        - name: excludeFallback
          in: query
          required: false
          schema: { type: string, enum: ["true"] }
          description: Exclude rows scored on fallback data (dataQuality = fallback).
        - name: sort
          in: query
          required: false
          schema: { type: string, enum: [composite, apy, tvl] }
          description: Sort descending by this field (apy and tvl are the live values). Omit to keep the default order.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200 }
          description: Return at most this many rows, applied after filtering and sorting.
        - name: fields
          in: query
          required: false
          schema: { type: string, enum: [compact] }
          description: compact returns the small CompactScore row instead of the full Score.
      responses:
        "200":
          description: Latest scores for the tracked vaults that match.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  scorerVersion: { type: string, example: "2.2.0" }
                  count: { type: integer, example: 570, description: "Rows returned." }
                  total: { type: integer, example: 570, description: "Rows matching the filters before limit is applied." }
                  disclaimer:
                    type: string
                    example: "Research and information, not investment advice."
                  data:
                    type: array
                    items:
                      oneOf:
                        - $ref: "#/components/schemas/Score"
                        - $ref: "#/components/schemas/CompactScore"
  /catalog:
    get:
      operationId: getCatalog
      summary: Vault catalog
      description: >-
        The tracked vault universe — every vault Atlas scores, with its live APY and TVL.
        Use it to discover vaultIds before querying /scores.
      parameters:
        - name: chainId
          in: query
          required: false
          schema: { type: integer }
          description: Filter to one EVM chain id.
          example: 8453
        - name: protocolId
          in: query
          required: false
          schema: { type: string }
          description: Filter to one protocol slug.
          example: morpho
      responses:
        "200":
          description: The tracked vault universe.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  count: { type: integer, example: 1076 }
                  disclaimer:
                    type: string
                    example: "Research and information, not investment advice."
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/CatalogEntry"
  /portfolio:
    get:
      operationId: getPortfolio
      summary: Build a portfolio
      description: >-
        The Atlas Engine as a service. Give it a risk tier, a timeline, and a principal;
        it runs the mean-variance allocator over the current scored universe and returns
        the resulting allocation, sized in dollars.


        Unlike /scores and /catalog, this endpoint **fails closed on stale scoring**: an
        allocation is an active recommendation, so if the most recent scoring run is too
        old the endpoint returns 503 rather than allocating on stale data.


        The allocator is deterministic — the same inputs against the same scoring
        snapshot return the same portfolio.
      parameters:
        - name: riskTier
          in: query
          required: true
          schema:
            type: string
            enum: [conservative, balanced, aggressive]
          description: Which tier policy to allocate under. See the tier policy page.
          example: balanced
        - name: principal
          in: query
          required: true
          schema:
            type: number
            minimum: 100
            maximum: 1000000
          description: Amount to allocate, USD. Must be between 100 and 1,000,000.
          example: 10000
        - name: timeline
          in: query
          required: false
          schema:
            type: string
            enum: [short, medium, long]
            default: medium
          description: >-
            Investment horizon. Nudges risk appetite within the tier's band — short is
            more variance-averse, long more return-seeking — and a short horizon also
            tightens the cap on vaults with little history.
          example: medium
      responses:
        "200":
          description: The proposed allocation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  riskTier: { type: string, example: balanced }
                  timeline: { type: string, example: medium }
                  principal: { type: number, example: 10000 }
                  strategy:
                    type: string
                    enum: [MVO, naive, none]
                    description: >-
                      Which allocator produced the result. MVO is the mean-variance
                      optimiser; naive is the equal-weight fallback; none means no
                      allocation could be produced (see `note`).
                    example: MVO
                  fallback:
                    type: boolean
                    description: True when the result came from the fallback path rather than MVO.
                    example: false
                  allocations:
                    type: array
                    items:
                      $ref: "#/components/schemas/PortfolioSlice"
                  expectedApy:
                    type: number
                    nullable: true
                    description: Principal-weighted expected APY of the allocation, percentage points.
                    example: 4.69
                  expectedSharpe:
                    type: number
                    description: Expected Sharpe-like ratio of the allocation.
                    example: 14.58
                  unallocatedUsd:
                    type: number
                    description: >-
                      Principal left in cash because policy caps could not be satisfied.
                      Surfaced honestly rather than force-fitted into a vault.
                    example: 0
                  scorerVersion: { type: string, example: "2.2.0" }
                  generatedAt:
                    type: string
                    format: date-time
                    description: ISO-8601 UTC timestamp the allocation was produced.
                  disclaimer:
                    type: string
                    example: "Research and information, not investment advice."
                  note:
                    type: string
                    description: Present only when the allocator could not fully allocate; explains why.
        "400":
          $ref: "#/components/responses/ValidationError"
        "503":
          description: >-
            Scoring data is stale, so no allocation was produced. Carries a `Retry-After`
            header (seconds).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: false }
                  error:
                    type: string
                    example: "Scoring data is stale — portfolio unavailable"
  /basket:
    post:
      operationId: scoreBasket
      summary: Score a basket
      description: >-
        Score a basket you define. Send up to 50 positions (chainId + vault address,
        optional weight) and get back each position's current Atlas Score plus a
        weighted basket score: composite, pillars, and APY, weighted by position size.
        Weights are normalised, so percentages, USD amounts, and fractions all work;
        omit them for equal weight.


        **Coverage is disclosed, never assumed.** A position Atlas does not score is
        returned with `scored: false` and excluded from the aggregate, and
        `basket.scoredWeight` reports the share of the basket the score actually
        covers. It is never averaged in as zero.


        A pure computation over the request body: nothing is stored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [positions]
              properties:
                positions:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items:
                    type: object
                    required: [chainId, address]
                    properties:
                      chainId: { type: integer, example: 1 }
                      address:
                        type: string
                        description: Vault contract address, case-insensitive.
                        example: "0xd8a9f8b3c1b52e8a4f1e2f6c4e0b1a9c8d7e6f5a"
                      weight:
                        type: number
                        exclusiveMinimum: 0
                        description: Position size in any unit; normalised across the basket. Omit on every position for equal weight.
                        example: 60
      responses:
        "200":
          description: The basket score and each position.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  disclaimer: { type: string, example: "Research and information, not investment advice." }
                  scorerVersion: { type: string, example: "2.2.0" }
                  generatedAt: { type: string, format: date-time }
                  basket:
                    type: object
                    properties:
                      composite:
                        type: number
                        nullable: true
                        description: Weight-weighted mean of the scored positions' composites. Null when nothing in the basket is scored.
                        example: 74.2
                      label: { type: string, nullable: true, example: Good }
                      pillarScores:
                        type: object
                        properties:
                          yield: { type: number, nullable: true }
                          safety: { type: number, nullable: true }
                          liquidity: { type: number, nullable: true }
                          sustainability: { type: number, nullable: true }
                      apy:
                        type: number
                        nullable: true
                        description: Weight-weighted live APY of the scored positions, percentage points.
                      positions: { type: integer, description: Positions sent. }
                      scoredPositions: { type: integer, description: Positions Atlas scores. }
                      scoredWeight:
                        type: number
                        description: Share of basket weight covered by the aggregate, 0 to 1. Below 1 means the basket holds something Atlas does not score.
                        example: 1
                      maxWeight: { type: number, description: Largest single position weight, 0 to 1. }
                      chainShare:
                        type: object
                        additionalProperties: { type: number }
                        description: Basket weight by chainId.
                      protocolShare:
                        type: object
                        additionalProperties: { type: number }
                        description: Basket weight by protocolId; unscored positions appear under `unscored`.
                  positions:
                    type: array
                    description: One entry per position sent, in order. Scored entries carry the same fields as a /scores row plus `weight`.
                    items:
                      type: object
                      properties:
                        chainId: { type: integer }
                        address: { type: string }
                        weight: { type: number, description: Normalised weight, 0 to 1. }
                        scored: { type: boolean }
                        vaultId: { type: string }
                        name: { type: string, nullable: true }
                        protocolId: { type: string }
                        composite: { type: number }
                        label: { type: string }
                        apy: { type: number, nullable: true }
                        tvl: { type: number, nullable: true }
                        dataQuality: { type: string, enum: [sufficient, partial, fallback] }
                        scoredAt: { type: string, format: date-time }
        "400":
          $ref: "#/components/responses/ValidationError"
  /vaults/{chainId}/{address}/factors:
    get:
      operationId: getVaultFactors
      summary: 16-factor breakdown for one vault
      description: >-
        The "why" behind a composite: every one of the 16 factors (15 additive across four
        pillars plus the multiplicative exploit-history modifier) with its sub-score, weight,
        raw input and a plain-English label. Served from the stored score row, so it is exactly
        what the engine computed at `scoredAt`, not a recomputation.
      parameters:
        - name: chainId
          in: path
          required: true
          schema: { type: integer }
          example: 8453
        - name: address
          in: path
          required: true
          schema: { type: string }
          description: Vault contract address. Case-insensitive.
          example: "0x4e65fe4dba92790696d040ac24aa414708f5c0ab"
      responses:
        "200":
          description: The latest score row's factor breakdown.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  disclaimer:
                    type: string
                    example: "Research and information, not investment advice."
                  data:
                    $ref: "#/components/schemas/VaultFactors"
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          description: The vault is not in the scored universe.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: false }
                  error: { type: string, example: "No score found for this vault" }
  /vaults/{chainId}/{address}/metrics:
    get:
      operationId: getVaultMetrics
      summary: Live risk metrics for one vault
      description: >-
        Tier 1 live metrics for a single vault: yield quality and exit safety, each with
        the reason it was assigned. These are **separate from the Atlas Score** and are
        never folded into it — the composite answers "how good is this vault", these two
        answer "is the yield real" and "can I get out".


        Both values are nullable and are never fabricated: when the underlying inputs are
        missing, the value is `null` and the reason says why. Metrics are recomputed on a
        ~24h cadence, so `scorerRunDate` is the day the values were produced.
      parameters:
        - name: chainId
          in: path
          required: true
          schema: { type: integer }
          description: EVM chain id the vault lives on.
          example: 8453
        - name: address
          in: path
          required: true
          schema: { type: string }
          description: Vault contract address. Case-insensitive.
          example: "0x4e65fe4dba92790696d040ac24aa414708f5c0ab"
      responses:
        "200":
          description: The most recent metrics row for the vault.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  disclaimer:
                    type: string
                    example: "Research and information, not investment advice."
                  data:
                    $ref: "#/components/schemas/VaultMetrics"
        "400":
          $ref: "#/components/responses/ValidationError"
        "404":
          description: No metrics have been computed for this vault.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: false }
                  error:
                    type: string
                    example: "Risk metrics not found for this vault"
  /vaults/{chainId}/{address}/route:
    get:
      operationId: getVaultRoute
      summary: Route-survival verdict for one vault
      description: >-
        The wedge, not the score. An agent about to move real money needs "can I get
        out at size, measured today" more than "is this good". This is the same verdict
        `refreshAllRouteScreens` writes daily and the live engine uses to gate deposits —
        exposed read-only. Round trip, on the protocol's own execution path, so no
        pricing oracle is involved: USDC compared against USDC.


        A vault that has never been screened returns `screened: false` with every
        measurement field `null` — never a fabricated verdict.
      parameters:
        - name: chainId
          in: path
          required: true
          schema: { type: integer }
          description: EVM chain id the vault lives on.
          example: 1
        - name: address
          in: path
          required: true
          schema: { type: string }
          description: Vault contract address. Case-insensitive.
          example: "0x5949c3bfdf9babd8bf011d9d8dbb1a2c3e10d8b8"
        - name: notional
          in: query
          required: false
          description: USD size to cost the round trip at. 10 to 10,000,000, default 5,000.
          schema: { type: number, minimum: 10, maximum: 10000000, default: 5000 }
      responses:
        "200":
          description: The most recent route-survival verdict for the vault, or the unscreened shape.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  disclaimer:
                    type: string
                    example: "Research and information, not investment advice."
                  data:
                    $ref: "#/components/schemas/VaultRoute"
        "400":
          $ref: "#/components/responses/ValidationError"
  /gas:
    get:
      operationId: getGasRegime
      summary: Per-chain gas regime (current + trailing 7-day percentiles)
      responses:
        "200":
          description: One entry per chain (1, 8453, 42161, 10, 56).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:    { type: boolean }
                  disclaimer: { type: string }
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/GasRegime" }
