openapi: "3.1.0"

info:
  title: HDB Resale Map API
  version: "1.6.0"
  description: |
    REST API for the Singapore HDB Resale Transaction Map (resalemap.sg).
    All data is public (data.gov.sg). No authentication required.
    The API is read-only.
    All API error responses (4xx, 5xx) are JSON objects with a single `error` string field,
    regardless of which Axum extractor triggered the rejection.

    **CORS.** No CORS headers are sent, deliberately: call the API from servers, scripts,
    notebooks and agents, not from third-party web pages. Browsers block cross-origin reads
    by default. Per-IP rate limiting (120 requests per 60 s) applies to all API routes;
    burst requests from a single IP return 429 Too Many Requests, and scan-heavy endpoints
    share a small concurrency cap (503 when saturated).

    **MCP.** A remote MCP server (Streamable HTTP, JSON-RPC 2.0, stateless) lives at
    `POST /mcp` with a curated tool surface: search_blocks, block_transactions,
    block_estimate, block_outlook, block_history, nearby_amenities, mop_radar,
    track_record, town_overview, price_index and affordability. Add it with
    `claude mcp add --transport http resalemap https://resalemap.sg/mcp`.

    **Fair use & stability.** Free public service, no SLA. Breaking changes bump this
    version and are recorded in the changelog below. Bulk raw data belongs upstream at
    data.gov.sg, not in crawls of this API. Derived outputs (model estimates, index
    values, scores) are provided as-is for individual use; redistribution as a dataset or
    systematic extraction is not permitted. Estimates are statistical output, not
    financial advice. Attribution (a link to resalemap.sg) is appreciated.

    **Changelog.**
    1.6.0: added /api/deal_finder, the conservative retrospective screen rendered
    at /deal-finder/.
    1.5.0: added /api/mrt_event_study (the MRT effect event study, rendered at
    /mrt-effect/).
    1.4.0: MCP tool surface expanded to 11 with nearby_amenities, mop_radar and
    track_record (no REST changes; these wrap existing endpoints).
    1.3.0: added /api/mop_radar (upcoming resale supply, rendered at /mop-radar/).
    1.2.1: the spec now documents the public read-only surface only; browser-internal
    endpoints are out of scope.
    1.2.0: first published version (the spec now serves at /openapi.yaml with rendered
    docs at /docs); added the /mcp server, the affordability endpoints and
    /api/block_history.
    1.1.0: full coverage of every served endpoint.
    1.0.0: initial spec.

servers:
  - url: https://resalemap.sg
    description: Production
  - url: http://localhost:3000
    description: Local development

# No authentication anywhere — all data is public.
security: []

tags:
  - name: health
    description: Liveness and readiness
  - name: transactions
    description: Resale transaction records
  - name: buildings
    description: Building polygons and per-block statistics
  - name: filters
    description: Static filter option lists (towns, months, lease range)
  - name: facets
    description: Dynamic cross-filter facet counts and town statistics
  - name: insights
    description: Block and town insight payloads (filter-independent)
  - name: valuation
    description: >
      Model-backed estimates, explained comparables, block outlook, public track
      record, and the quality-adjusted price index. All valuation endpoints serve
      explanation or reference data — none accept map filters.
  - name: amenities
    description: Nearby amenities and map amenity markers
  - name: reference
    description: Static reference payloads (record schemas, national medians)
  - name: rentals
    description: HDB rental records
  - name: search
    description: Address and postal-code search
  - name: trivia
    description: Random "Did you know?" facts

# Reusable parameter definitions
components:
  parameters:
    towns:
      name: towns
      in: query
      style: form
      explode: false
      description: >
        HDB town names. Values must match the strings returned by
        `/api/towns_by_region`. Omit to include all towns.
      schema:
        type: array
        items:
          type: string
      example: ["BEDOK", "TAMPINES"]

    flat_combos:
      name: flat_combos
      in: query
      style: form
      explode: false
      description: >
        Exact `(flat_type, flat_model)` pairs, each encoded as `"TYPE|MODEL"`
        (pipe-separated). A transaction matches if its pair equals any listed
        value. The pipe `|` is outside RFC 3986's allowed query characters;
        strict clients should percent-encode it as `%7C` on the wire — the
        server accepts both forms because URL decoding runs before the `|`
        separator is parsed. Values must match the `type`+`model` fields from
        the `flat_combos` array in `/api/filtered_facets`. Omit to include all
        combinations.
      schema:
        type: array
        items:
          type: string
          description: One pair in `"TYPE|MODEL"` form.
      example: ["4 ROOM|Improved", "5 ROOM|Model A"]

    storey_ranges:
      name: storey_ranges
      in: query
      style: form
      explode: false
      description: >
        Storey range strings. Values must match the `range` strings from the
        `storey_ranges` array in `/api/filtered_facets`. Omit to include all
        storeys.
      schema:
        type: array
        items:
          type: string
      example: ["01 TO 03", "04 TO 06"]

    min_month:
      name: min_month
      in: query
      description: Earliest transaction month to include, in `YYYY-MM` format.
      schema:
        type: string
        pattern: '^\d{4}-\d{2}$'
      example: "2020-01"

    max_month:
      name: max_month
      in: query
      description: Latest transaction month to include, in `YYYY-MM` format.
      schema:
        type: string
        pattern: '^\d{4}-\d{2}$'
      example: "2024-12"

    min_lease_start:
      name: min_lease_start
      in: query
      description: >
        Minimum `lease_commence_date` year (per-transaction field). Drives the
        "Remaining lease" slider in the UI. Valid range from `/api/lease_range`.
      schema:
        type: integer
        minimum: 1960
      example: 1990

    max_lease_start:
      name: max_lease_start
      in: query
      description: >
        Maximum `lease_commence_date` year (per-transaction field). Drives the
        "Remaining lease" slider in the UI. Valid range from `/api/lease_range`.
      schema:
        type: integer
        minimum: 1960
      example: 2010

    min_floor_area:
      name: min_floor_area
      in: query
      description: Minimum floor area in square metres.
      schema:
        type: number
        format: double
      example: 60.0

    max_floor_area:
      name: max_floor_area
      in: query
      description: Maximum floor area in square metres.
      schema:
        type: number
        format: double
      example: 120.0

    min_price:
      name: min_price
      in: query
      description: Minimum resale price in SGD.
      schema:
        type: number
        format: double
      example: 300000

    max_price:
      name: max_price
      in: query
      description: Maximum resale price in SGD.
      schema:
        type: number
        format: double
      example: 800000

    min_build_year:
      name: min_build_year
      in: query
      description: >
        Minimum block completion year (`property_info.year_completed`).
        Block-level gate: applied before per-transaction filtering.
        Exposed only for trivia click-through; not in the filter modal.
      schema:
        type: integer
      example: 1980

    max_build_year:
      name: max_build_year
      in: query
      description: >
        Maximum block completion year (`property_info.year_completed`).
        Block-level gate: applied before per-transaction filtering.
        Exposed only for trivia click-through; not in the filter modal.
      schema:
        type: integer
      example: 2000

  schemas:
    Coord:
      type: integer
      description: >
        Fixed-point coordinate: latitude or longitude in degrees multiplied by
        1,000,000 and rounded (e.g. 1347937 = 1.347937°). Divide by 1e6 to get
        degrees. Used by every endpoint that returns map coordinates.
      example: 1347937

    TransactionRow:
      type: array
      description: >
        One transaction as a compact positional array. Field order is fixed and
        also published by `/api/record_schemas` (`transactions.fields`):
        month, town, flat_type, block, street_name, storey_range,
        floor_area_sqm, flat_model, lease_commence_date, remaining_lease,
        resale_price.
      prefixItems:
        - type: string
          description: Transaction month in `YYYY-MM` format.
        - type: string
          description: HDB town.
        - type: string
          description: "Flat type, e.g. `4 ROOM`, `EXECUTIVE`, `MULTI GENERATION`."
        - type: string
          description: Block number.
        - type: string
          description: Street name (normalised).
        - type: string
          description: "Storey range, e.g. `04 TO 06`."
        - type: number
          format: double
          description: Floor area in square metres.
        - type: string
          description: "Flat model, e.g. `Improved`, `Model A`."
        - type: integer
          description: Year the lease began.
        - type: integer
          description: Remaining lease in total months at time of transaction (0 for pre-2015 rows).
        - type: number
          format: double
          description: Resale price in SGD.
      example: ["2026-04", "JURONG WEST", "3 ROOM", "211", "BOON LAY PL",
                "01 TO 03", 65.0, "Improved", 1976, 595, 325000.0]

    PropertyInfoRow:
      type: array
      description: >
        Block-level HDB property information as a compact positional array.
        Field order is published by `/api/record_schemas`
        (`buildings.property_info.fields`): max_floor_lvl, year_completed,
        total_dwelling_units, commercial, market_hawker, multistorey_carpark,
        precinct_pavilion, room1_sold, room2_sold, room3_sold, room4_sold,
        room5_sold, exec_sold, multigen_sold, studio_sold, room1_rental,
        room2_rental, room3_rental, other_rental.
      prefixItems:
        - type: integer
        - type: integer
        - type: integer
        - type: boolean
        - type: boolean
        - type: boolean
        - type: boolean
        - type: integer
        - type: integer
        - type: integer
        - type: integer
        - type: integer
        - type: integer
        - type: integer
        - type: integer
        - type: integer
        - type: integer
        - type: integer
        - type: integer

    NearestMrtRow:
      type: array
      description: >
        Nearest MRT/LRT exit as a compact positional array. Field order is
        published by `/api/record_schemas` (`buildings.nearest_mrt.fields`):
        name, exit_code, distance_m.
      prefixItems:
        - type: string
        - type: string
        - type: integer
      example: ["Pioneer", "Exit B", 1147]

    BuildingRow:
      type: array
      description: >
        One building as a compact positional array. Field order is published by
        `/api/record_schemas` (`buildings.fields`): postal_code, block,
        street_name, lat, lng, polygon, total_transactions, filtered_count,
        lease_commence_date, avg_price_psf_24mo, price_txn_count,
        property_info, nearest_mrt.
      prefixItems:
        - type: string
          description: Postal code.
        - type: string
          description: Block number.
        - type: string
          description: Street name (normalised).
        - $ref: '#/components/schemas/Coord'
        - $ref: '#/components/schemas/Coord'
        - type: array
          description: Building footprint as an outer ring of fixed-point [lat, lng] pairs.
          items:
            type: array
            minItems: 2
            maxItems: 2
            items:
              $ref: '#/components/schemas/Coord'
        - type: integer
          description: All-time transaction count (filter-independent).
        - type: integer
          description: Transactions matching current filters.
        - type: integer
          description: Year the lease began.
        - type: integer
          description: Average price per sq ft over the last 24 months (filter-independent).
        - type: integer
          description: Number of transactions behind avg_price_psf_24mo.
        - description: Property info row, or null when the block is absent from the HDB dataset.
          oneOf:
            - type: "null"
            - $ref: '#/components/schemas/PropertyInfoRow'
        - description: Nearest MRT exit row, or null when none within 3 km.
          oneOf:
            - type: "null"
            - $ref: '#/components/schemas/NearestMrtRow'

    BuildingListItem:
      type: object
      required: [postal_code, block, street_name, lat, lng, filtered_count,
                 avg_price_psf_24mo, price_breakdown_24mo, flat_type_counts,
                 lease_commence_date]
      properties:
        postal_code:
          type: string
        block:
          type: string
        street_name:
          type: string
        lat:
          $ref: '#/components/schemas/Coord'
        lng:
          $ref: '#/components/schemas/Coord'
        filtered_count:
          type: integer
          description: Transactions matching current filters.
        avg_price_psf_24mo:
          type: integer
          description: Average price per sq ft over last 24 months (filter-independent).
        price_breakdown_24mo:
          type: array
          items:
            type: object
            properties:
              flat_type:
                type: string
              min:
                type: integer
              max:
                type: integer
        flat_type_counts:
          type: array
          items:
            type: object
            properties:
              flat_type:
                type: string
              count:
                type: integer
        lease_commence_date:
          type: integer
        nearest_mrt:
          description: Present only when an MRT/LRT exit exists within 3 km.
          $ref: '#/components/schemas/NearestMrt'

    BinCount:
      type: object
      properties:
        lo:
          type: integer
        hi:
          description: Null for the open-ended last bin.
          oneOf:
            - type: "null"
            - type: integer
        count:
          type: integer

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: string

    MopRadarBlock:
      type: object
      required: [block, street_name, postal_code, completed, units, mix, resales]
      properties:
        block:
          type: string
        street_name:
          type: string
        postal_code:
          type: string
        completed:
          type: integer
          description: Completion year from HDB's property register.
        units:
          type: integer
          description: Resale-eligible sold flats (Studio Apartments excluded).
        mix:
          type: object
          description: Sold-flat count per flat type; zero-count types omitted.
          additionalProperties:
            type: integer
        resales:
          type: integer
          description: Recorded resale transactions so far.
        studio_units:
          type: integer
          description: >
            Sold Studio Apartments (short leases, cannot be resold). Omitted
            when zero.
        town:
          type: string
          description: Overdue entries only.
        est_mop_year:
          type: integer
          description: Overdue entries only.

    PercentileInsight:
      type: object
      description: >
        Where the block's median PSF sits among its town's blocks. `basis` is
        `24mo` (median PSF over the last 24 months) or `adjusted` (inflation-
        normalised all-time sales, used when the block has too few recent sales).
      required: [pct_below, block_psf, town_blocks, sample, basis, range, cohort]
      properties:
        pct_below:
          type: integer
          description: Percent of the town's blocks whose median PSF sits below this block's.
        block_psf:
          type: integer
        town_blocks:
          type: integer
        sample:
          type: integer
        basis:
          type: string
          enum: [24mo, adjusted]
        range:
          oneOf:
            - type: "null"
            - type: object
              required: [min, median, max]
              properties:
                min:
                  type: integer
                median:
                  type: integer
                max:
                  type: integer
        cohort:
          description: >
            The same ranking restricted to town blocks whose lease started within
            ±2 years of this block's ("is it cheap for its age").
          oneOf:
            - type: "null"
            - type: object
              required: [pct_below, blocks]
              properties:
                pct_below:
                  type: integer
                blocks:
                  type: integer
                range:
                  oneOf:
                    - type: "null"
                    - type: object
                      required: [min, median, max]
                      properties:
                        min:
                          type: integer
                        median:
                          type: integer
                        max:
                          type: integer

    NearestMrt:
      type: object
      description: Nearest MRT/LRT exit within 3 km of the building.
      required: [name, exit_code, distance_m]
      properties:
        name:
          type: string
          description: Station name (e.g. "Tampines East").
        exit_code:
          type: string
          description: Exit label (e.g. "A").
        distance_m:
          type: integer
          description: Straight-line distance in metres (truncated integer).

    TriviaFilter:
      type: object
      description: >
        Filter state to apply on trivia click-through. All fields are optional;
        absent fields leave the corresponding UI filter unchanged. `min_build_year`
        and `max_build_year` gate at the building level (`property_info.year_completed`)
        and are not exposed in the filter modal.
      properties:
        towns:
          type: array
          items:
            type: string
        flat_combos:
          type: array
          items:
            type: string
        min_month:
          type: string
          pattern: '^\d{4}-\d{2}$'
        max_month:
          type: string
          pattern: '^\d{4}-\d{2}$'
        min_price:
          type: number
          format: double
        min_lease_start:
          type: integer
        max_lease_start:
          type: integer
        min_build_year:
          type: integer
        max_build_year:
          type: integer

    TriviaAddress:
      type: object
      description: Block address used for map fly-to on trivia click-through.
      required: [block, street_name]
      properties:
        block:
          type: string
        street_name:
          type: string

    DealFinderItem:
      type: object
      description: A completed resale that clears every confidence and uncertainty gate.
      required: [month, town, block, street_name, flat_type, flat_model, storey_range,
                 floor_area_sqm, price, estimate, lo, hi, discount_pct,
                 interval_width_pct, uncertainty_score, comparables_24mo]
      properties:
        month:
          type: string
          pattern: '^\d{4}-\d{2}$'
        town:
          type: string
        block:
          type: string
        street_name:
          type: string
        flat_type:
          type: string
        flat_model:
          type: string
        storey_range:
          type: string
        floor_area_sqm:
          type: number
        price:
          type: integer
          description: Recorded resale price in SGD.
        estimate:
          type: integer
          description: Retrospective model estimate for the same unit and sale month, rounded to $1k.
        lo:
          type: integer
          description: Lower edge of the 80% model range, rounded to $1k.
        hi:
          type: integer
          description: Upper edge of the 80% model range, rounded to $1k.
        discount_pct:
          type: number
          description: Percentage by which recorded price is below the model estimate.
        interval_width_pct:
          type: number
          description: Full 80% range width as a percentage of estimate.
        uncertainty_score:
          type: number
          description: Estimate-to-price gap divided by the full 80% range width.
        comparables_24mo:
          type: integer
          description: Same-flat-type resales at the block in the inclusive 24-month window.

  responses:
    BadRequest:
      description: A query parameter could not be parsed.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    InternalServerError:
      description: Unexpected server error.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

paths:
  /health:
    get:
      tags: [health]
      operationId: getHealth
      summary: Liveness check
      responses:
        "200":
          description: Service is alive.
          content:
            text/plain:
              schema:
                type: string
                example: ok
        "500":
          $ref: '#/components/responses/InternalServerError'

  /ready:
    get:
      tags: [health]
      operationId: getReady
      summary: Readiness check
      description: >
        Returns 200 when transactions, buildings, and towns are all loaded.
        Returns 503 during startup before data is ready.
      responses:
        "200":
          description: Service is ready and data is loaded.
          content:
            application/json:
              schema:
                type: object
                required: [ok, transactions, buildings, towns]
                properties:
                  ok:
                    type: boolean
                  transactions:
                    type: integer
                    description: Total loaded transaction count.
                  buildings:
                    type: integer
                    description: Total loaded building count.
                  towns:
                    type: integer
                    description: Total loaded town count.
                  data_last_updated:
                    type: [string, "null"]
                    description: ISO 8601 timestamp of the last data import, shown in the modal footer.
        "503":
          description: Data not yet loaded.
          content:
            application/json:
              schema:
                type: object
                required: [ok, transactions, buildings, towns]
                properties:
                  ok:
                    type: boolean
                    enum: [false]
                  transactions:
                    type: integer
                  buildings:
                    type: integer
                  towns:
                    type: integer

  /api/transactions:
    get:
      tags: [transactions]
      operationId: getTransactions
      summary: Block-scoped filtered resale transactions
      description: >
        Returns all transactions for the given block matching the supplied filters.
        `block` and `street_name` are both required — this endpoint is block-scoped
        only and uses a pre-built per-block index (O(~20) lookups) for fast response.
        Results are returned newest-first. No pagination — block transaction counts
        are always small enough to return in full.
        Rows are compact positional arrays; the field order is published by
        `/api/record_schemas`.
      parameters:
        - name: block
          in: query
          required: true
          description: Block number (e.g. `123A`).
          schema:
            type: string
          example: "376"
        - name: street_name
          in: query
          required: true
          description: Street name (normalised before comparison).
          schema:
            type: string
          example: "CLEMENTI AVE 4"
        - $ref: '#/components/parameters/towns'
        - $ref: '#/components/parameters/flat_combos'
        - $ref: '#/components/parameters/storey_ranges'
        - $ref: '#/components/parameters/min_month'
        - $ref: '#/components/parameters/max_month'
        - $ref: '#/components/parameters/min_lease_start'
        - $ref: '#/components/parameters/max_lease_start'
        - $ref: '#/components/parameters/min_floor_area'
        - $ref: '#/components/parameters/max_floor_area'
        - $ref: '#/components/parameters/min_price'
        - $ref: '#/components/parameters/max_price'
      responses:
        "200":
          description: Matching transactions for the block, newest-first.
          content:
            application/json:
              schema:
                type: object
                required: [transactions]
                properties:
                  transactions:
                    type: array
                    items:
                      $ref: '#/components/schemas/TransactionRow'
        "400":
          $ref: '#/components/responses/BadRequest'
        "422":
          description: Missing required parameter (`block` or `street_name`).
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/buildings:
    get:
      tags: [buildings]
      operationId: getBuildings
      summary: Buildings in a bounding box with filtered counts
      description: >
        Returns all buildings within the supplied bbox, with `filtered_count`
        (transactions matching current filters) and `avg_price_psf_24mo` (last 24 months,
        filter-independent). Called at zoom >= 16. The bbox should be padded by 50%
        to avoid pop-in at the edges.
        Rows are compact positional arrays; the field order is published by
        `/api/record_schemas`. Alternatively, `postals` (up to 8 comma-joined postal
        codes) switches to a direct postal-code lookup that bypasses the bbox and
        building-level gates — used to hydrate `#compare=` links.
      parameters:
        - name: min_lat
          in: query
          schema:
            type: number
            format: double
        - name: max_lat
          in: query
          schema:
            type: number
            format: double
        - name: min_lng
          in: query
          schema:
            type: number
            format: double
        - name: max_lng
          in: query
          schema:
            type: number
            format: double
        - name: postals
          in: query
          style: form
          explode: false
          description: >
            Comma-joined postal codes (max 8). When present, the bbox params and
            building-level gates are ignored and the listed blocks are returned
            directly.
          schema:
            type: array
            items:
              type: string
        - $ref: '#/components/parameters/towns'
        - $ref: '#/components/parameters/flat_combos'
        - $ref: '#/components/parameters/storey_ranges'
        - $ref: '#/components/parameters/min_month'
        - $ref: '#/components/parameters/max_month'
        - $ref: '#/components/parameters/min_lease_start'
        - $ref: '#/components/parameters/max_lease_start'
        - $ref: '#/components/parameters/min_build_year'
        - $ref: '#/components/parameters/max_build_year'
        - $ref: '#/components/parameters/min_floor_area'
        - $ref: '#/components/parameters/max_floor_area'
        - $ref: '#/components/parameters/min_price'
        - $ref: '#/components/parameters/max_price'
      responses:
        "200":
          description: Buildings in bbox (or the requested postal codes).
          content:
            application/json:
              schema:
                type: object
                required: [buildings]
                properties:
                  buildings:
                    type: array
                    items:
                      $ref: '#/components/schemas/BuildingRow'
        "400":
          $ref: '#/components/responses/BadRequest'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/buildings_list:
    get:
      tags: [buildings]
      operationId: getBuildingsList
      summary: Paginated buildings list sorted by filtered transaction count
      description: >
        Returns residential buildings matching the current filters, sorted by
        `filtered_count` descending. Supports page-based pagination (default 20/page,
        max 50). Stale page requests are clamped to the last valid page.
        Aggregate totals (`total_matched`, `total_transactions`) are included in every
        response — no separate count endpoint needed.
      parameters:
        - name: page
          in: query
          description: 1-indexed page number (default 1, clamped to total_pages).
          schema:
            type: integer
            default: 1
        - name: page_size
          in: query
          description: Results per page (default 20, clamped to 1–50).
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 50
        - $ref: '#/components/parameters/towns'
        - $ref: '#/components/parameters/flat_combos'
        - $ref: '#/components/parameters/storey_ranges'
        - $ref: '#/components/parameters/min_month'
        - $ref: '#/components/parameters/max_month'
        - $ref: '#/components/parameters/min_lease_start'
        - $ref: '#/components/parameters/max_lease_start'
        - $ref: '#/components/parameters/min_build_year'
        - $ref: '#/components/parameters/max_build_year'
        - $ref: '#/components/parameters/min_floor_area'
        - $ref: '#/components/parameters/max_floor_area'
        - $ref: '#/components/parameters/min_price'
        - $ref: '#/components/parameters/max_price'
      responses:
        "200":
          description: Paginated buildings list with aggregate totals.
          content:
            application/json:
              schema:
                type: object
                required: [buildings, total_matched, total_transactions, page, page_size, total_pages]
                properties:
                  buildings:
                    type: array
                    items:
                      $ref: '#/components/schemas/BuildingListItem'
                  total_matched:
                    type: integer
                    description: Total buildings matching the current filters.
                  total_transactions:
                    type: integer
                    description: Total transactions across matched buildings.
                  page:
                    type: integer
                  page_size:
                    type: integer
                  total_pages:
                    type: integer
        "400":
          $ref: '#/components/responses/BadRequest'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/filtered_facets:
    get:
      tags: [facets]
      operationId: getFilteredFacets
      summary: Cross-filter facet counts for the filter UI
      description: >
        One scan over all transactions returning four self-excluded cross-filter
        payloads: storey-range counts (storey filter excluded), flat-combo counts
        (flat-combo filter excluded), price histogram (price filter excluded),
        floor-area histogram (floor-area filter excluded). Also returns
        `total_transactions` for the modal count preview. Called once per filter
        change from the modal.
      parameters:
        - $ref: '#/components/parameters/towns'
        - $ref: '#/components/parameters/flat_combos'
        - $ref: '#/components/parameters/storey_ranges'
        - $ref: '#/components/parameters/min_month'
        - $ref: '#/components/parameters/max_month'
        - $ref: '#/components/parameters/min_lease_start'
        - $ref: '#/components/parameters/max_lease_start'
        - $ref: '#/components/parameters/min_floor_area'
        - $ref: '#/components/parameters/max_floor_area'
        - $ref: '#/components/parameters/min_price'
        - $ref: '#/components/parameters/max_price'
      responses:
        "200":
          description: Facet counts for all filter dimensions plus aggregate totals.
          content:
            application/json:
              schema:
                type: object
                required: [storey_ranges, flat_combos, price_bins, floor_area_bins,
                           total_transactions]
                properties:
                  storey_ranges:
                    type: array
                    items:
                      type: object
                      required: [range, count]
                      properties:
                        range:
                          type: string
                        count:
                          type: integer
                  flat_combos:
                    type: array
                    items:
                      type: object
                      required: [type, model, count]
                      properties:
                        type:
                          type: string
                        model:
                          type: string
                        count:
                          type: integer
                  price_bins:
                    type: array
                    items:
                      $ref: '#/components/schemas/BinCount'
                  floor_area_bins:
                    type: array
                    items:
                      $ref: '#/components/schemas/BinCount'
                  total_transactions:
                    type: integer
                    description: Total transactions matching the current filters.
        "400":
          $ref: '#/components/responses/BadRequest'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/town_stats:
    get:
      tags: [facets]
      operationId: getTownStats
      summary: Per-town filtered and total transaction counts
      description: >
        Returns all HDB towns with transaction counts under the current filters.
        Does not include polygon geometry — polygon data is static and served
        separately by `/api/town_polygons`. The client merges the two by town name.
      parameters:
        - $ref: '#/components/parameters/towns'
        - $ref: '#/components/parameters/flat_combos'
        - $ref: '#/components/parameters/storey_ranges'
        - $ref: '#/components/parameters/min_month'
        - $ref: '#/components/parameters/max_month'
        - $ref: '#/components/parameters/min_lease_start'
        - $ref: '#/components/parameters/max_lease_start'
        - $ref: '#/components/parameters/min_floor_area'
        - $ref: '#/components/parameters/max_floor_area'
        - $ref: '#/components/parameters/min_price'
        - $ref: '#/components/parameters/max_price'
      responses:
        "200":
          description: Towns with filtered and total counts.
          content:
            application/json:
              schema:
                type: object
                required: [towns]
                properties:
                  towns:
                    type: array
                    items:
                      type: object
                      required: [name, count, total]
                      properties:
                        name:
                          type: string
                        region:
                          oneOf:
                            - type: "null"
                            - type: string
                        count:
                          type: integer
                          description: Transactions matching current filters.
                        total:
                          type: integer
                          description: All-time transaction count (filter-independent).
        "400":
          $ref: '#/components/responses/BadRequest'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/town_polygons:
    get:
      tags: [facets]
      operationId: getTownPolygons
      summary: Static polygon geometry for all planning areas
      description: >
        Returns polygon rings for all URA planning areas. This data is static — it
        never changes at runtime. The client fetches it once at startup and caches it
        for the session. Use the area `name` as the join key with `/api/town_stats`.
        Coordinates are fixed-point integers (see `Coord`).
      responses:
        "200":
          description: Polygon geometry for all planning areas, sorted by name.
          content:
            application/json:
              schema:
                type: object
                required: [polygons]
                properties:
                  polygons:
                    type: array
                    items:
                      type: object
                      required: [name, region, polygon]
                      properties:
                        name:
                          type: string
                          description: URA planning area name (PLN_AREA_N).
                        region:
                          type: string
                          description: URA region name.
                        polygon:
                          description: >
                            Outer rings (array of rings, each a list of fixed-point
                            [lat, lng] pairs).
                          type: array
                          items:
                            type: array
                            items:
                              type: array
                              minItems: 2
                              maxItems: 2
                              items:
                                $ref: '#/components/schemas/Coord'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/months:
    get:
      tags: [filters]
      operationId: getMonths
      summary: Sorted unique transaction months
      description: Fetched once at startup. Used to set date-picker bounds.
      responses:
        "200":
          description: Array of `YYYY-MM` strings, ascending.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string
                  pattern: '^\d{4}-\d{2}$'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/storey_ranges:
    get:
      tags: [filters]
      operationId: getStoreyRanges
      summary: All known storey range labels
      description: >
        Fetched once at startup. Used to validate storey-range values from shared
        URLs before the filter modal is opened.
      responses:
        "200":
          description: Array of storey range strings (e.g. `"01 TO 03"`), sorted.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/lease_range:
    get:
      tags: [filters]
      operationId: getLeaseRange
      summary: Min and max lease commencement year across all transactions
      description: >
        Fetched once at startup. Used to set slider bounds for the "Remaining lease"
        filter. Not filter-dependent — always reflects the full dataset range.
      responses:
        "200":
          description: Lease year range.
          content:
            application/json:
              schema:
                type: object
                required: [min, max]
                properties:
                  min:
                    type: integer
                    description: Earliest `lease_commence_date` in the dataset.
                  max:
                    type: integer
                    description: Latest `lease_commence_date` in the dataset.
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/towns_by_region:
    get:
      tags: [filters]
      operationId: getTownsByRegion
      summary: HDB towns grouped by URA region
      description: >
        Used to populate the town filter modal. Towns with no URA polygon
        (e.g. CENTRAL AREA) appear under "OTHER".
      responses:
        "200":
          description: Regions with their towns.
          content:
            application/json:
              schema:
                type: object
                required: [regions]
                properties:
                  regions:
                    type: array
                    items:
                      type: object
                      required: [region, towns]
                      properties:
                        region:
                          type: string
                        towns:
                          type: array
                          items:
                            type: string
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/rentals:
    get:
      tags: [rentals]
      operationId: getBlockRentals
      summary: Rental records for a block
      description: Returns all rental records for the given block. Not filtered — rentals have no sidebar filters.
      parameters:
        - name: block
          in: query
          required: true
          schema:
            type: string
          example: "123A"
        - name: street_name
          in: query
          required: true
          schema:
            type: string
          example: "ANG MO KIO AVE 1"
      responses:
        "200":
          description: Rental records for the block.
          content:
            application/json:
              schema:
                type: object
                required: [rentals]
                properties:
                  rentals:
                    type: array
                    items:
                      type: object
                      required: [rent_approval_date, flat_type, monthly_rent]
                      properties:
                        rent_approval_date:
                          type: string
                          description: Approval month in YYYY-MM format.
                        flat_type:
                          type: string
                        monthly_rent:
                          type: integer
        "400":
          $ref: '#/components/responses/BadRequest'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/search:
    get:
      tags: [search]
      operationId: searchPlaces
      summary: Search by address or postal code
      description: >
        Plan A: local HDB DB search (postal code exact match, or token AND-match
        across `blk <block> <street_name>`). Plan B: OneMap API proxy fallback,
        used only when Plan A returns nothing. Results carry a `type` field
        (`hdb`, `mrt`, `bus`, `school`, `hospital`, `shopping`, `hotel`,
        `religious`, `condo`, `place`).
      parameters:
        - name: q
          in: query
          required: true
          description: Search query (min 2 characters).
          schema:
            type: string
            minLength: 2
            maxLength: 200
          example: "ang mo kio 216"
      responses:
        "200":
          description: Search results from local DB or OneMap.
          content:
            application/json:
              schema:
                type: object
                required: [results, source]
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      required: [type, building_name, address, lat, lng]
                      properties:
                        type:
                          type: string
                          enum: [hdb, mrt, bus, school, hospital, shopping, hotel, religious, condo, place]
                        postal_code:
                          type: string
                        block:
                          type: string
                          description: >
                            Present only for `type: hdb` results.
                        street_name:
                          type: string
                          description: >
                            Present only for `type: hdb` results.
                        building_name:
                          type: string
                        address:
                          type: string
                        lat:
                          $ref: '#/components/schemas/Coord'
                        lng:
                          $ref: '#/components/schemas/Coord'
                  source:
                    type: string
                    enum: [local, onemap]
                  fallback_failed:
                    type: boolean
                    description: >
                      Present and `true` only when `source` is `onemap` and the OneMap
                      request failed. Otherwise omitted from the JSON object.
                  degraded:
                    description: >
                      Present when the search is operating in a degraded mode
                      (e.g. OneMap circuit-breaker open). Omitted when fully operational.
                    type: object
                    properties:
                      mode:
                        type: string
                      message:
                        type: string
        "400":
          description: Query validation failed (fewer than 2 or more than 200 characters).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/trivia:
    get:
      tags: [trivia]
      operationId: getTrivia
      summary: Random weighted trivia facts
      description: >
        Returns up to `n` facts from the precomputed catalog, excluding IDs the
        client has already seen this session. Facts are weighted so global counters
        surface more often than per-town records. `cycle_complete=true` signals the
        client to clear its seen-list.
      parameters:
        - name: n
          in: query
          description: Number of facts to return (default 5, max 20).
          schema:
            type: integer
            default: 5
            minimum: 1
            maximum: 20
        - name: exclude
          in: query
          description: >
            Comma-joined list of fact IDs already seen this session.
            Bounded in practice by Hyper's ~8 KB URI limit.
          schema:
            type: string
      responses:
        "200":
          description: Sampled trivia facts.
          content:
            application/json:
              schema:
                type: object
                required: [facts, cycle_complete, pool_total]
                properties:
                  facts:
                    type: array
                    items:
                      type: object
                      required: [id, label, value]
                      properties:
                        id:
                          type: string
                        label:
                          type: string
                        value:
                          type: string
                        detail:
                          type: string
                        town:
                          description: Town name for town-scoped facts. Omitted for global facts.
                          type: string
                        filter:
                          description: Filter state to apply on click-through. Omitted when no filter applies.
                          $ref: '#/components/schemas/TriviaFilter'
                        address:
                          description: Block address for map fly-to on click-through. Omitted when not pinned to a single block.
                          oneOf:
                            - type: "null"
                            - $ref: '#/components/schemas/TriviaAddress'
                  cycle_complete:
                    type: boolean
                  pool_total:
                    type: integer
        "400":
          $ref: '#/components/responses/BadRequest'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/record_schemas:
    get:
      tags: [reference]
      operationId: getRecordSchemas
      summary: Field-order metadata for compact row arrays
      description: >
        Publishes the positional field order used by the compact transaction and
        building row arrays (`/api/transactions`, `/api/buildings`), so clients can
        index rows by name instead of hardcoding positions. Static per deploy.
      responses:
        "200":
          description: Field lists for each compact row type.
          content:
            application/json:
              schema:
                type: object
                required: [transactions, buildings]
                properties:
                  transactions:
                    type: object
                    required: [fields]
                    properties:
                      fields:
                        type: array
                        items:
                          type: string
                  buildings:
                    type: object
                    required: [fields, property_info, nearest_mrt]
                    properties:
                      fields:
                        type: array
                        items:
                          type: string
                      property_info:
                        type: object
                        required: [fields]
                        properties:
                          fields:
                            type: array
                            items:
                              type: string
                      nearest_mrt:
                        type: object
                        required: [fields]
                        properties:
                          fields:
                            type: array
                            items:
                              type: string
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/sg_medians:
    get:
      tags: [reference]
      operationId: getSgMedians
      summary: National median resale price per flat type and month
      description: >
        Columnar series of the national median resale price per (flat type, month),
        pre-serialised at boot. Filter-independent reference data for benchmark
        lines; fetch once and cache for the session. Months with fewer than 5 sales
        of a flat type are dropped.
      responses:
        "200":
          description: One columnar series per flat type, sorted by flat type.
          content:
            application/json:
              schema:
                type: object
                required: [series]
                properties:
                  series:
                    type: array
                    items:
                      type: object
                      required: [flat_type, months, medians, counts]
                      properties:
                        flat_type:
                          type: string
                        months:
                          type: array
                          items:
                            type: string
                            pattern: '^\d{4}-\d{2}$'
                        medians:
                          type: array
                          description: Median resale price (SGD) per month, same index as `months`.
                          items:
                            type: integer
                        counts:
                          type: array
                          description: Sale count behind each median, same index as `months`.
                          items:
                            type: integer
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/mop_radar:
    get:
      tags: [reference]
      operationId: getMopRadar
      summary: Upcoming resale supply (MOP radar)
      description: >
        Every residential block whose estimated 5-year Minimum Occupation Period
        lifts this year or later, grouped by estimated MOP year then town, plus
        blocks whose window already passed with zero recorded resales.
        Pre-serialised at boot; the data only changes when the underlying datasets
        refresh. The MOP year is the block's completion year plus 5 and is a
        cohort bucket, never a date: MOP actually runs from each unit's key
        collection date, which no public dataset carries. Unit counts are HDB's
        per-flat-type sold counts (rental flats excluded; non-resalable Studio
        Apartments listed separately; short-lease 2-room Flexi flats are not
        separable from 99-year 2-room flats, so 2-room counts overstate tradable
        supply). Rendered at /mop-radar/.
      responses:
        "200":
          description: The full radar payload.
          content:
            application/json:
              schema:
                type: object
                required: [as_of, mop_years, basis, years, overdue]
                properties:
                  as_of:
                    type: string
                    description: Latest transaction month in the dataset.
                  data_last_updated:
                    type: [string, "null"]
                  mop_years:
                    type: integer
                  basis:
                    type: string
                    description: Plain-language statement of how the estimate works.
                  years:
                    type: array
                    description: Ascending by estimated MOP year.
                    items:
                      type: object
                      required: [year, blocks, units, towns]
                      properties:
                        year:
                          type: integer
                        blocks:
                          type: integer
                        units:
                          type: integer
                          description: Resale-eligible sold flats summed over the year's blocks.
                        towns:
                          type: array
                          description: Sorted by units descending.
                          items:
                            type: object
                            required: [town, new_estate, blocks, units, items]
                            properties:
                              town:
                                type: string
                                description: >
                                  HDB town, or the URA planning-area name for areas
                                  with no resale history yet.
                              new_estate:
                                type: boolean
                                description: True when the whole area has no resale history.
                              blocks:
                                type: integer
                              units:
                                type: integer
                              items:
                                type: array
                                items:
                                  $ref: '#/components/schemas/MopRadarBlock'
                  overdue:
                    type: array
                    description: >
                      Blocks past the estimated window with zero recorded resales,
                      ascending by estimated MOP year. Each entry adds `town` and
                      `est_mop_year` to the block fields.
                    items:
                      $ref: '#/components/schemas/MopRadarBlock'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/deal_finder:
    get:
      tags: [valuation]
      operationId: getDealFinder
      summary: Recent completed resales unusually low relative to the model
      description: >
        A retrospective screen, rendered at /deal-finder/. Results are completed
        registered resales, not listings or recommendations. Every item must be
        from the latest 12 months, at least 10% below estimate, below the lower
        edge of the model's 80% range, backed by at least three same-type block
        resales in 24 months, attached to fitted street and block effects, and
        carry an interval no wider than 40% of estimate. The current model can
        include transactions from the screened period, so this is not a locked
        pre-sale forecast or investment advice.
      parameters:
        - name: town
          in: query
          schema:
            type: string
          description: One exact HDB town name.
        - name: flat_type
          in: query
          schema:
            type: string
            enum: [1 ROOM, 2 ROOM, 3 ROOM, 4 ROOM, 5 ROOM, EXECUTIVE, MULTI GENERATION]
        - name: min_price
          in: query
          schema:
            type: integer
            minimum: 0
          description: Minimum recorded resale price in SGD.
        - name: max_price
          in: query
          schema:
            type: integer
            minimum: 0
          description: Maximum recorded resale price in SGD.
        - name: sort
          in: query
          schema:
            type: string
            enum: [uncertainty_adjusted, discount]
            default: uncertainty_adjusted
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
          description: Maximum items returned. Out-of-range values are clamped to [1, 100], not rejected.
      responses:
        "200":
          description: Matching screened resales and the exact eligibility policy.
          content:
            application/json:
              schema:
                type: object
                required: [available, as_of, retrospective, sort, total, criteria, items]
                properties:
                  available:
                    type: boolean
                    description: False when no compatible valuation model is loaded.
                  as_of:
                    type: string
                  retrospective:
                    type: boolean
                    const: true
                  sort:
                    type: string
                    enum: [uncertainty_adjusted, discount]
                  total:
                    type: integer
                    description: Eligible count before the response limit.
                  criteria:
                    type: object
                    required: [recent_months, comparable_months, min_comparables,
                               min_block_confidence, min_discount_pct,
                               max_interval_width_pct, requires_below_interval]
                    properties:
                      recent_months: { type: integer }
                      comparable_months: { type: integer }
                      min_comparables: { type: integer }
                      min_block_confidence: { type: number }
                      min_discount_pct: { type: number }
                      max_interval_width_pct: { type: number }
                      requires_below_interval: { type: boolean }
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/DealFinderItem'
        "400":
          $ref: '#/components/responses/BadRequest'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/mrt_event_study:
    get:
      tags: [reference]
      operationId: getMrtEventStudy
      summary: The MRT effect (event study of station openings)
      description: >
        An event study of every MRT/LRT station opening since 1993 on nearby HDB
        resale prices, computed from the full transaction history at boot.
        Outcome: ln(price per sqm) of 3/4/5-room resales, demeaned by the
        (town, flat type, quarter) median and residualised on remaining lease,
        tracked in crow-flight bands (0-400/400-800/800-1200 m to the nearest
        exit) around each opening, each band differenced against its own
        pre-opening years -5..-2; the beyond-1200 m same-town band is the
        placebo. Events are first-rail-at-location, graded MRT vs LRT; blocks
        already within 1.2 km of open rail of the same grade are excluded, and
        results pool into cohorts (infill in mature estates, new lines through
        mature estates, greenfield, LRT, added line at an existing station)
        that are never averaged together. Values are log points. This is a
        historical association, not a forecast; openings are announced years
        ahead so figures understate the full premium. Rendered at /mrt-effect/.
      responses:
        "200":
          description: The full study payload.
          content:
            application/json:
              schema:
                type: object
                required: [as_of, method, cohorts, stations]
                properties:
                  as_of:
                    type: string
                    description: Latest transaction month in the dataset.
                  data_last_updated:
                    type: [string, "null"]
                  method:
                    type: object
                    description: >
                      Plain-language method note: outcome construction, band
                      edges, baseline and estimate windows, maturity rule, data
                      gates, caveats and the openings-table provenance.
                  cohorts:
                    type: array
                    items:
                      type: object
                      required: [key, label, events, insufficient_events, blocks, bands]
                      properties:
                        key:
                          type: string
                          enum: [infill_mature, line_mature, greenfield, lrt, added_line]
                        label:
                          type: string
                        events:
                          type: integer
                          description: Stations that passed the data gates.
                        insufficient_events:
                          type: integer
                        blocks:
                          type: integer
                        bands:
                          type: array
                          description: >
                            Four entries: three treated bands plus the placebo.
                            Points are event-time years -5..+5; v is the pooled
                            baseline-differenced mean in log points, null when
                            no event contributes enough sales that year.
                          items:
                            type: object
                            properties:
                              band:
                                type: string
                              points:
                                type: array
                                items:
                                  type: object
                                  properties:
                                    t:
                                      type: integer
                                    v:
                                      type: [number, "null"]
                                    events:
                                      type: integer
                        estimate_iqr_0_400:
                          type: [object, "null"]
                          description: >
                            p25/p50/p75 of per-station 0-400 m estimates
                            (years +2..+5 minus baseline), across gate-passing
                            stations.
                        within_block_0_400:
                          type: [object, "null"]
                          description: >
                            Repeat-sales-style robustness cut, comparing each
                            0-400 m block only against itself.
                  stations:
                    type: array
                    description: One row per event, ascending by opening date.
                    items:
                      type: object
                      required: [station, lines, opened, kind, cohort, blocks, insufficient]
                      properties:
                        station:
                          type: string
                        lines:
                          type: array
                          items:
                            type: string
                        opened:
                          type: string
                          description: Opening month, YYYY-MM.
                        kind:
                          type: string
                          enum: [first_rail, added_line]
                        cohort:
                          type: string
                        blocks:
                          type: integer
                        n_pre:
                          type: integer
                        n_post:
                          type: integer
                        est_0_400:
                          type: [number, "null"]
                          description: Log points; null when the band is empty.
                        est_0_400_n:
                          type: integer
                        est_400_800:
                          type: [number, "null"]
                        insufficient:
                          type: boolean
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/affordability:
    get:
      tags: [insights]
      operationId: getAffordability
      summary: Max-affordable-price envelope for a buyer profile
      description: >
        Turns a buyer profile into the maximum affordable resale price per flat
        type and remaining-lease year, computed from published HDB, MAS, CPF and
        IRAS rules (MSR 30% at the 3% HDB / 4% bank stress floors, TDSR 55%,
        LTV 75%, lease-coverage CPF pro-ration to age 95, loan tenure caps,
        EHG and Family/Singles Grant tables, buyer's stamp duty, ABSD for PR
        couples). Both loan types are evaluated and the better one wins per
        point. Stateless: the profile is not stored and not logged. Assumes
        first-timer buyers; the Proximity Housing Grant is excluded.
      parameters:
        - name: household
          in: query
          required: true
          schema:
            type: string
            enum: [couple_sc, couple_sc_spr, couple_spr, single]
        - name: income
          in: query
          required: true
          description: Gross monthly household income (SGD). Clamped to 0..200000.
          schema:
            type: number
        - name: cash
          in: query
          required: true
          description: Cash savings for the purchase (SGD). Clamped to 0..5000000.
          schema:
            type: number
        - name: cpf
          in: query
          required: true
          description: CPF OA savings for the purchase (SGD). Clamped to 0..5000000.
          schema:
            type: number
        - name: age
          in: query
          required: true
          description: Age of the youngest buyer. Clamped to 21..80; singles must be 35+.
          schema:
            type: integer
        - name: debts
          in: query
          required: false
          description: >
            Existing monthly non-property repayments (SGD); reduces the bank
            loan through TDSR. Default 0.
          schema:
            type: number
        - name: cov
          in: query
          required: false
          description: >
            Assumed cash over valuation (SGD). Loan and CPF caps apply to price
            minus this amount and it must be paid in cash. Default 0.
          schema:
            type: number
      responses:
        "200":
          description: >
            Envelope per flat type (3Gen excluded). Arrays are indexed by
            remaining lease year: element 0 is 1 year left, element 98 is 99.
          content:
            application/json:
              schema:
                type: object
                required: [profile, lease_years_start, assumptions, flat_types]
                properties:
                  profile:
                    type: object
                    description: The sanitised profile the envelope was computed from.
                  lease_years_start:
                    type: integer
                    description: Remaining-lease year of array element 0 (always 1).
                  assumptions:
                    type: object
                    description: Machine-readable policy constants and modelling assumptions used.
                  flat_types:
                    type: array
                    items:
                      type: object
                      required: [flat_type, grants, max_price, rule, loan]
                      properties:
                        flat_type:
                          type: string
                        grants:
                          type: object
                          required: [ehg, cpf_grant]
                          properties:
                            ehg:
                              type: number
                              description: EHG at full lease coverage; pro-rated internally on shorter leases.
                            cpf_grant:
                              type: number
                              description: Family or Singles Grant (not lease pro-rated).
                        max_price:
                          type: array
                          description: Max affordable price (SGD, floored to $1,000) per remaining-lease year.
                          items:
                            type: integer
                        rule:
                          type: array
                          description: Binding constraint at each ceiling.
                          items:
                            type: string
                            enum: [income, downpayment, cash_floor, lease_cpf, no_financing]
                        loan:
                          type: array
                          description: Loan type that wins at each point.
                          items:
                            type: string
                            enum: [hdb, bank, none]
        "400":
          $ref: '#/components/responses/BadRequest'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/affordability_prices:
    get:
      tags: [reference]
      operationId: getAffordabilityPrices
      summary: Per-block price basis for the affordability heatmap
      description: >
        For every residential block and flat type the block has traded: the
        24-month median resale price where at least 3 recent sales of that type
        exist, otherwise the valuation model's typical-unit estimate (the same
        representative unit the block insights card uses). Pre-serialised at
        boot and filter-independent; fetch once and cache for the session.
      responses:
        "200":
          description: Compact entries keyed by postal code.
          content:
            application/json:
              schema:
                type: object
                required: [flat_types, cutoff, blocks]
                properties:
                  flat_types:
                    type: array
                    description: Type order; entry element 0 indexes into this list.
                    items:
                      type: string
                  cutoff:
                    type: string
                    description: First month (YYYY-MM) inside the 24-month window.
                  blocks:
                    type: object
                    description: >
                      postal_code -> array of [type_idx, price, basis, recent_sales]
                      with basis 0 = sales median, 1 = model estimate.
                    additionalProperties:
                      type: array
                      items:
                        type: array
                        items:
                          type: integer
                        minItems: 4
                        maxItems: 4
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/town_detail:
    get:
      tags: [insights]
      operationId: getTownDetail
      summary: One town's statistics under the current filters
      description: >
        Drill-down payload for the town panel: monthly medians per flat type,
        flat-type summary, most-traded blocks, lease histogram, year-over-year
        change, and dwelling stock. Accepts the core filter set (no
        `min/max_build_year` — there is deliberately no building-level gate here).
      parameters:
        - name: town
          in: query
          required: true
          description: HDB town name.
          schema:
            type: string
          example: "BEDOK"
        - $ref: '#/components/parameters/towns'
        - $ref: '#/components/parameters/flat_combos'
        - $ref: '#/components/parameters/storey_ranges'
        - $ref: '#/components/parameters/min_month'
        - $ref: '#/components/parameters/max_month'
        - $ref: '#/components/parameters/min_lease_start'
        - $ref: '#/components/parameters/max_lease_start'
        - $ref: '#/components/parameters/min_floor_area'
        - $ref: '#/components/parameters/max_floor_area'
        - $ref: '#/components/parameters/min_price'
        - $ref: '#/components/parameters/max_price'
      responses:
        "200":
          description: Town statistics under the current filters.
          content:
            application/json:
              schema:
                type: object
                required: [town, total_transactions, filtered_transactions,
                           total_buildings, monthly_medians, flat_type_summary,
                           top_blocks, median_remaining_lease, lease_histogram,
                           flat_type_stock]
                properties:
                  town:
                    type: string
                  region:
                    oneOf:
                      - type: "null"
                      - type: string
                  total_transactions:
                    type: integer
                  filtered_transactions:
                    type: integer
                  total_buildings:
                    type: integer
                  date_range:
                    type: object
                    properties:
                      earliest:
                        type: string
                      latest:
                        type: string
                  monthly_medians:
                    type: array
                    items:
                      type: object
                      properties:
                        month:
                          type: string
                        flat_type:
                          type: string
                        median:
                          type: integer
                        count:
                          type: integer
                  flat_type_summary:
                    type: array
                    items:
                      type: object
                      properties:
                        flat_type:
                          type: string
                        count:
                          type: integer
                        median_price:
                          type: integer
                        median_psf:
                          type: integer
                  top_blocks:
                    type: array
                    description: Most-traded blocks under the current filters (max 10).
                    items:
                      type: object
                      properties:
                        block:
                          type: string
                        street_name:
                          type: string
                        postal_code:
                          type: string
                        lat:
                          $ref: '#/components/schemas/Coord'
                        lng:
                          $ref: '#/components/schemas/Coord'
                        count:
                          type: integer
                        median_price:
                          type: integer
                  median_remaining_lease:
                    type: integer
                  lease_histogram:
                    type: array
                    items:
                      type: object
                      properties:
                        label:
                          type: string
                          description: "`<50yr`, `50-60yr`, ... `90+yr`."
                        count:
                          type: integer
                  yoy_change:
                    description: >
                      Median price change vs the same 12-month window a year earlier,
                      in percent. Null when either window has no filtered sales.
                    oneOf:
                      - type: "null"
                      - type: integer
                  flat_type_stock:
                    type: array
                    description: Dwelling units per flat type from the HDB property register, not sale counts.
                    items:
                      type: object
                      properties:
                        flat_type:
                          type: string
                        units:
                          type: integer
        "400":
          $ref: '#/components/responses/BadRequest'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/block_insights:
    get:
      tags: [insights]
      operationId: getBlockInsights
      summary: Insights tab payload for one block
      description: >
        Filter-independent block insights: market-position percentiles, gross rental
        yields, sales velocity, latest sale (with a cautious below-market flag),
        model estimates per flat type, storey premium curve, lease projection, and
        comparable recent sales. Active map filters are deliberately not accepted.
        Model-backed fields (`estimates`, `storey_premium`, `lease_projection`) are
        omitted entirely when no valuation model is loaded. An unknown block returns
        200 with empty/absent sections, not 404.
      parameters:
        - name: block
          in: query
          required: true
          schema:
            type: string
        - name: street_name
          in: query
          required: true
          schema:
            type: string
        - name: preferred_type
          in: query
          required: false
          description: >
            Subject flat type for the single estimate row and the per-type cards
            (storey premium, lease, comparables). Not a filter; an invalid or
            untraded value falls back to the block's most-traded covered type. The
            resolved value is echoed back as `preferred_type`.
          schema:
            type: string
      responses:
        "200":
          description: Block insights.
          content:
            application/json:
              schema:
                type: object
                required: [town, percentile, flat_percentiles, yields, velocity, latest_sale]
                properties:
                  town:
                    oneOf:
                      - type: "null"
                      - type: string
                  percentile:
                    description: Mixed-flat-type town rank, used when per-type ranks are unavailable.
                    oneOf:
                      - type: "null"
                      - $ref: '#/components/schemas/PercentileInsight'
                  flat_percentiles:
                    type: array
                    description: Per-flat-type town rank; PercentileInsight fields are flattened beside `flat_type`.
                    items:
                      allOf:
                        - type: object
                          required: [flat_type]
                          properties:
                            flat_type:
                              type: string
                        - $ref: '#/components/schemas/PercentileInsight'
                  yields:
                    type: array
                    items:
                      type: object
                      required: [flat_type, rent_median, price_estimate, n, source, yield_pct]
                      properties:
                        flat_type:
                          type: string
                        rent_median:
                          type: integer
                          description: Trailing-12-month median monthly rent (SGD).
                        price_estimate:
                          type: integer
                          description: Fair-value estimate used as the yield denominator.
                        n:
                          type: integer
                          description: Rental approvals behind the median.
                        source:
                          type: string
                          enum: [block, street]
                        yield_pct:
                          type: number
                          description: Gross yearly yield in percent, one decimal.
                  velocity:
                    oneOf:
                      - type: "null"
                      - type: object
                        required: [sales_12mo, sales_5y, town_block_median_12mo]
                        properties:
                          sales_12mo:
                            type: integer
                          sales_5y:
                            type: integer
                          town_block_median_12mo:
                            type: integer
                            description: Median 12-month sale count among town blocks that traded; 0 when the town is too small to compare.
                  latest_sale:
                    oneOf:
                      - type: "null"
                      - type: object
                        required: [month, price, flat_type, storey_range]
                        properties:
                          month:
                            type: string
                          price:
                            type: integer
                          flat_type:
                            type: string
                          storey_range:
                            type: string
                          below_market:
                            description: >
                              Present only for a recent sale materially below the model
                              estimate for that exact unit at its sale date. A cautious
                              signal, not a valuation claim.
                            type: object
                            required: [estimate, below_pct]
                            properties:
                              estimate:
                                type: integer
                              below_pct:
                                type: integer
                  estimates:
                    type: array
                    description: One fair-value estimate per flat type the block has traded. Omitted when no model is loaded.
                    items:
                      type: object
                      required: [flat_type, estimate, lo, hi, storey_mid, floor_area_sqm, max_floor, areas]
                      properties:
                        flat_type:
                          type: string
                        estimate:
                          type: integer
                          description: Point estimate in SGD, rounded to $1k.
                        lo:
                          type: integer
                          description: 80% conformal interval lower bound, rounded to $1k.
                        hi:
                          type: integer
                          description: 80% conformal interval upper bound, rounded to $1k.
                        storey_mid:
                          type: number
                          description: Representative unit's mid-storey the estimate priced.
                        floor_area_sqm:
                          type: number
                        max_floor:
                          type: integer
                          description: Building's top storey; bounds the personalize level picker.
                        areas:
                          type: array
                          description: Distinct floor sizes (sqm) this flat type has traded at, as quick picks.
                          items:
                            type: integer
                  storey_premium:
                    description: Storey-only PSF curve for one representative unit. Omitted without a model.
                    type: object
                    required: [flat_type, floor_area_sqm, typical_storey_mid, bands]
                    properties:
                      flat_type:
                        type: string
                      floor_area_sqm:
                        type: number
                      typical_storey_mid:
                        type: number
                      bands:
                        type: array
                        items:
                          type: object
                          required: [lo, hi, psf, premium_pct]
                          properties:
                            lo:
                              type: integer
                            hi:
                              type: integer
                            psf:
                              type: integer
                            premium_pct:
                              type: integer
                              description: Percent change vs the lowest band.
                  lease_projection:
                    description: >
                      Lease-decay scenario anchored to the current estimate via a 3.5%
                      DCF relativity curve (re-prices only the remaining lease).
                      Omitted without a model or when 20 or fewer years remain.
                    type: object
                    required: [flat_type, current_years, current_estimate, points]
                    properties:
                      flat_type:
                        type: string
                      current_years:
                        type: integer
                      current_estimate:
                        type: integer
                      points:
                        type: array
                        items:
                          type: object
                          required: [years_left, estimate]
                          properties:
                            years_left:
                              type: integer
                            estimate:
                              type: integer
                  comparables:
                    description: >
                      Recent sales of similar flats nearby (same flat type, ±10% area,
                      lease start within ±5 years, within 1 km). Omitted when nothing
                      matched.
                    type: object
                    required: [flat_type, window_months, n, median_price, median_psf, sales]
                    properties:
                      flat_type:
                        type: string
                      window_months:
                        type: integer
                        description: 12, widened to 24 when fewer than 3 sales matched.
                      n:
                        type: integer
                        description: Every match; `sales` is capped to the newest 20.
                      median_price:
                        type: integer
                      median_psf:
                        type: integer
                      sales:
                        type: array
                        items:
                          type: object
                          required: [block, street_name, month, price, psf,
                                     floor_area_sqm, storey_range, distance_m]
                          properties:
                            block:
                              type: string
                            street_name:
                              type: string
                            month:
                              type: string
                            price:
                              type: integer
                            psf:
                              type: integer
                            floor_area_sqm:
                              type: number
                            storey_range:
                              type: string
                            distance_m:
                              type: integer
                  uncovered_models:
                    type: array
                    description: >
                      Flat models the block traded that the model can only price
                      through a generic bucket, so their estimates were suppressed.
                      Omitted when every traded type is covered.
                    items:
                      type: string
                  preferred_type:
                    type: string
                    description: >
                      The resolved subject flat type the single estimate row and the
                      per-type cards are computed for. Omitted when the block has no
                      estimable type.
                  unpriced_types:
                    type: array
                    description: >
                      Flat types the block traded that have no estimate (modal model is
                      an uncovered typology). Listed as limited-data options in the UI's
                      room-type selector. Omitted when empty.
                    items:
                      type: string
                  low_confidence_estimate:
                    description: >
                      A hedged estimate for `preferred_type` when that subject type is
                      itself unpriced (rare layout priced through the generic bucket).
                      Present only then; the UI shows it behind a caution banner (no
                      personalize control or range), and the storey/lease/comparables
                      cards are anchored on this hedged figure. Same shape as an
                      `estimates` item.
                    type: object
                    required: [flat_type, estimate, lo, hi, storey_mid, floor_area_sqm, max_floor, areas]
                    properties:
                      flat_type:
                        type: string
                      flat_model:
                        type: string
                        description: The block's modal flat model for this type (the rare layout).
                      estimate:
                        type: integer
                      lo:
                        type: integer
                      hi:
                        type: integer
                      storey_mid:
                        type: number
                      floor_area_sqm:
                        type: number
                      max_floor:
                        type: integer
                      areas:
                        type: array
                        items:
                          type: integer
        "400":
          $ref: '#/components/responses/BadRequest'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/estimate:
    get:
      tags: [valuation]
      operationId: getEstimate
      summary: Model estimate for a user-specified unit
      description: >
        Personalize endpoint: fair-value estimate for a specific unit (flat type +
        level + floor area) at a block. The block's lease, town and location context
        are resolved server-side. Storey is clamped to [1, 60] and floor area to
        [20, 250] sqm before inference; the response echoes the values actually
        priced.
      parameters:
        - name: block
          in: query
          required: true
          schema:
            type: string
        - name: street_name
          in: query
          required: true
          schema:
            type: string
        - name: flat_type
          in: query
          required: true
          schema:
            type: string
          example: "4 ROOM"
        - name: storey_mid
          in: query
          required: true
          schema:
            type: number
        - name: floor_area_sqm
          in: query
          required: true
          schema:
            type: number
      responses:
        "200":
          description: Estimate for the requested unit.
          content:
            application/json:
              schema:
                type: object
                required: [flat_type, estimate, lo, hi, storey_mid, floor_area_sqm]
                properties:
                  flat_type:
                    type: string
                  estimate:
                    type: integer
                    description: Point estimate in SGD, rounded to $1k.
                  lo:
                    type: integer
                  hi:
                    type: integer
                  storey_mid:
                    type: number
                    description: The clamped value actually fed to the model.
                  floor_area_sqm:
                    type: number
                    description: The clamped value actually fed to the model.
        "400":
          description: Missing block/street, unknown flat_type, or non-finite numeric input.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "404":
          description: No valuation model loaded, unknown block, or the block cannot be valued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/comparables_explained:
    get:
      tags: [valuation]
      operationId: getComparablesExplained
      summary: Comparable sales adjusted to the subject unit, line by line
      description: >
        The same gated comparable sales as the insights card, each adjusted to the
        subject unit through the valuation model with named dollar line items
        (market, location, size, storey, lease, flat_model, transit). Line dollars
        sum exactly to `adjusted`. The subject defaults to the block's typical unit
        for its latest-traded flat type; `flat_type`/`storey_mid`/`floor_area_sqm`
        personalize it. Explanation only — never feeds the prediction.
      parameters:
        - name: block
          in: query
          required: true
          schema:
            type: string
        - name: street_name
          in: query
          required: true
          schema:
            type: string
        - name: flat_type
          in: query
          schema:
            type: string
        - name: storey_mid
          in: query
          schema:
            type: number
        - name: floor_area_sqm
          in: query
          schema:
            type: number
      responses:
        "200":
          description: The most similar comparables (smallest total adjustment first, max 5).
          content:
            application/json:
              schema:
                type: object
                required: [flat_type, storey_mid, floor_area_sqm, estimate, lo, hi,
                           window_months, low_confidence, n_considered, comps]
                properties:
                  flat_type:
                    type: string
                  storey_mid:
                    type: number
                  floor_area_sqm:
                    type: number
                  estimate:
                    type: integer
                  lo:
                    type: integer
                  hi:
                    type: integer
                  window_months:
                    type: integer
                  low_confidence:
                    type: boolean
                    description: >
                      True when the subject's layout is priced through the generic OTHER
                      bucket (a rare typology). The walk still ties out to `estimate`, but
                      that figure is the card's hedged number and the per-comp layout step
                      uses a soft coefficient. Clients should frame it as a rough guide.
                  n_considered:
                    type: integer
                    description: Every sale that passed the gates; `comps` keeps the 5 most similar.
                  implied_lo:
                    type: integer
                    description: Smallest adjusted comp price, rounded to $1k. Omitted when no comps.
                  implied_hi:
                    type: integer
                    description: Largest adjusted comp price, rounded to $1k. Omitted when no comps.
                  comps:
                    type: array
                    items:
                      type: object
                      required: [block, street_name, month, price, floor_area_sqm,
                                 storey_range, distance_m, adjusted, lines]
                      properties:
                        block:
                          type: string
                        street_name:
                          type: string
                        month:
                          type: string
                        price:
                          type: integer
                          description: Actual sale price.
                        floor_area_sqm:
                          type: number
                        storey_range:
                          type: string
                        distance_m:
                          type: integer
                        adjusted:
                          type: integer
                          description: "`price` plus the rounded line items — the displayed arithmetic is exact."
                        lines:
                          type: array
                          items:
                            type: object
                            required: [key, dollars]
                            properties:
                              key:
                                type: string
                                enum: [market, location, size, storey, lease, flat_model, transit]
                              dollars:
                                type: integer
        "400":
          $ref: '#/components/responses/BadRequest'
        "404":
          description: No model, unknown block, unknown flat type, or no unit profile.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/block_outlook:
    get:
      tags: [valuation]
      operationId: getBlockOutlook
      summary: Lease-decay path and rental yield for a block's typical unit
      description: >
        Cross-sectional outlook in today's market: the subject unit re-priced
        through the DCF lease-relativity curve at now, minus 5 and minus 10 years
        of lease, anchored on the served estimate. The conformal interval applies
        to the anchor only — projected lease ages carry no bands. Dollar
        projections stop at 30 years remaining (`lease_floored` flags a truncated
        sweep). Gross yield holds today's median rent flat over the decaying
        price; rent is never extrapolated. Subject defaults and personalize params
        match `/api/comparables_explained`. Explanation only — never feeds the
        prediction.
      parameters:
        - name: block
          in: query
          required: true
          schema:
            type: string
        - name: street_name
          in: query
          required: true
          schema:
            type: string
        - name: flat_type
          in: query
          schema:
            type: string
        - name: storey_mid
          in: query
          schema:
            type: number
        - name: floor_area_sqm
          in: query
          schema:
            type: number
      responses:
        "200":
          description: Lease path and yield outlook for the subject unit.
          content:
            application/json:
              schema:
                type: object
                required: [flat_type, storey_mid, floor_area_sqm, current_years,
                           estimate, lo, hi, lease_path, lease_floored]
                properties:
                  flat_type:
                    type: string
                  storey_mid:
                    type: number
                  floor_area_sqm:
                    type: number
                  current_years:
                    type: integer
                    description: Remaining lease years as of the model's data month.
                  estimate:
                    type: integer
                    description: Anchor estimate in SGD, rounded to $1k.
                  lo:
                    type: integer
                  hi:
                    type: integer
                  lease_path:
                    type: array
                    description: First point is the anchor (retained_pct 100.0).
                    items:
                      type: object
                      required: [years_left, estimate, retained_pct]
                      properties:
                        years_left:
                          type: integer
                        estimate:
                          type: integer
                        retained_pct:
                          type: number
                          description: Leasehold value retained vs today per the DCF curve, one decimal.
                  lease_floored:
                    type: boolean
                    description: True when the 10-year sweep crossed the 30-year floor and points were dropped.
                  rental:
                    description: Omitted when the block and street have too few rental records.
                    type: object
                    required: [monthly_rent, n, source, current_pct, path]
                    properties:
                      monthly_rent:
                        type: integer
                        description: Trailing-12-month median monthly rent (SGD).
                      n:
                        type: integer
                      source:
                        type: string
                        enum: [block, street]
                      current_pct:
                        type: number
                        description: Gross yearly yield today, one decimal.
                      path:
                        type: array
                        description: Yield at each lease-path point with today's rent held flat.
                        items:
                          type: object
                          required: [years_left, pct]
                          properties:
                            years_left:
                              type: integer
                            pct:
                              type: number
        "400":
          $ref: '#/components/responses/BadRequest'
        "404":
          description: No model, unknown block, unknown flat type, or no unit profile.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/block_history:
    get:
      tags: [valuation]
      operationId: getBlockHistory
      summary: Monthly model-history series for a block's typical units
      description: >
        What the model values the block's typical unit at, month by month over
        the training window, one series per flat type the block has ever traded.
        Each point re-encodes the unit as it stood that month (the remaining
        lease ages with the calendar; storey, size and flat model stay the
        block's typical unit), so the path carries block-specific signal rather
        than a level-shifted copy of the price index. Months after
        `anchor_month` are drift-extrapolated nowcasts. The 80% interval is a
        today-interval and attaches to the latest point only. Values are
        rounded to $1k. Explanation only — never feeds the prediction.
      parameters:
        - name: block
          in: query
          required: true
          schema:
            type: string
        - name: street_name
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Model-history series for the block.
          content:
            application/json:
              schema:
                type: object
                required: [months, series]
                properties:
                  months:
                    type: array
                    description: '"YYYY-MM" per point, ascending; only months the model fitted a level for.'
                    items:
                      type: string
                  anchor_month:
                    type: string
                    description: >
                      Months after this are drift-extrapolated nowcasts. Omitted
                      when the model has no anchor (every point then rests on its
                      own fitted dummy).
                  series:
                    type: array
                    items:
                      type: object
                      required: [flat_type, storey_mid, floor_area_sqm, values, lo, hi]
                      properties:
                        flat_type:
                          type: string
                        storey_mid:
                          type: number
                        floor_area_sqm:
                          type: number
                        values:
                          type: array
                          description: One SGD value per entry of `months`, rounded to $1k.
                          items:
                            type: integer
                        lo:
                          type: integer
                          description: 80% interval at the latest month only.
                        hi:
                          type: integer
        "400":
          $ref: '#/components/responses/BadRequest'
        "404":
          description: No model, unknown block, or no valuable unit profile.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/report:
    get:
      tags: [valuation]
      operationId: getReport
      summary: Buyer report for one block and one chosen unit
      description: >
        Assembles the four subject-specific sections of the buyer report for one block
        and one user-chosen unit: fair-value estimate, nearby comparable sales,
        lease-decay path and model history. One resolved subject drives every section,
        so the headline ties out to /api/estimate for the same unit (a fourth serving
        path). `storey_mid` / `floor_area_sqm` default to the block median for the flat
        type and are clamped to sane HDB ranges server-side. Reads only public
        transaction data, holds no personal input and persists nothing; the report page
        layers affordability and the model track record client-side. An unknown or
        unpriceable block returns 200 with `available: false` and a `reason`, not 404.
      parameters:
        - name: block
          in: query
          required: true
          schema:
            type: string
        - name: street_name
          in: query
          required: true
          schema:
            type: string
        - name: flat_type
          in: query
          required: true
          schema:
            type: string
          example: "4 ROOM"
        - name: storey_mid
          in: query
          required: false
          description: Subject storey midpoint. Defaults to the block median for the type; clamped server-side.
          schema:
            type: number
        - name: floor_area_sqm
          in: query
          required: false
          description: Subject floor area (sqm). Defaults to the block median for the type; clamped server-side.
          schema:
            type: number
      responses:
        "200":
          description: >
            The report. When the unit cannot be priced, `available` is false with a
            `reason` and the section fields are absent.
          content:
            application/json:
              schema:
                type: object
                required: [available, as_of]
                properties:
                  available:
                    type: boolean
                  reason:
                    type: string
                    description: Why the report could not be produced. Present only when `available` is false.
                  as_of:
                    type: string
                    description: The data month the report is priced as of ("YYYY-MM"), not today's date.
                  subject:
                    type: object
                    description: The resolved subject. Absent on an unavailable report.
                    required: [block, street_name]
                    properties:
                      block:
                        type: string
                      street_name:
                        type: string
                      town:
                        type: string
                  estimate:
                    type: object
                    description: Fair-value point estimate and 80% interval for the subject unit.
                    required: [flat_type, flat_model, estimate, lo, hi, storey_mid, floor_area_sqm, max_floor, areas]
                    properties:
                      flat_type:
                        type: string
                      flat_model:
                        type: string
                        description: The block's modal flat model the estimate priced (never the OTHER bucket).
                      estimate:
                        type: integer
                        description: Point estimate in SGD, rounded to $1k.
                      lo:
                        type: integer
                      hi:
                        type: integer
                      storey_mid:
                        type: number
                      floor_area_sqm:
                        type: number
                      max_floor:
                        type: integer
                        description: The building's top storey (bounds the level picker).
                      areas:
                        type: array
                        description: Distinct floor sizes (sqm) this flat type has traded at, offered as quick picks.
                        items:
                          type: integer
                  comparables:
                    type: object
                    description: Recent sales of similar flats within walking distance. Absent when none matched.
                    required: [flat_type, window_months, n, median_price, median_psf, sales]
                    properties:
                      flat_type:
                        type: string
                      window_months:
                        type: integer
                        description: 12, widened to 24 when fewer than 3 sales matched the 12-month window.
                      n:
                        type: integer
                      median_price:
                        type: integer
                      median_psf:
                        type: integer
                      sales:
                        type: array
                        description: Newest matches, capped at 20.
                        items:
                          type: object
                          required: [block, street_name, month, price, psf, floor_area_sqm, storey_range, distance_m]
                          properties:
                            block:
                              type: string
                            street_name:
                              type: string
                            month:
                              type: string
                            price:
                              type: integer
                            psf:
                              type: integer
                            floor_area_sqm:
                              type: number
                            storey_range:
                              type: string
                            distance_m:
                              type: integer
                  lease_path:
                    type: object
                    description: Lease-decay path anchored on the report's own headline estimate.
                    required: [flat_type, current_years, current_estimate, points]
                    properties:
                      flat_type:
                        type: string
                      current_years:
                        type: integer
                        description: Years of lease remaining today.
                      current_estimate:
                        type: integer
                        description: Headline estimate the path starts from (SGD).
                      points:
                        type: array
                        items:
                          type: object
                          required: [years_left, estimate]
                          properties:
                            years_left:
                              type: integer
                            estimate:
                              type: integer
                  history:
                    type: object
                    description: The one chosen unit re-priced at every fitted month of the training window.
                    required: [months, series]
                    properties:
                      months:
                        type: array
                        description: '"YYYY-MM" per point, ascending.'
                        items:
                          type: string
                      anchor_month:
                        type: string
                        description: Months after this are drift-extrapolated nowcasts. Absent when the model has no anchor.
                      series:
                        type: array
                        description: A single series for the chosen unit.
                        items:
                          type: object
                          required: [flat_type, storey_mid, floor_area_sqm, values, lo, hi]
                          properties:
                            flat_type:
                              type: string
                            storey_mid:
                              type: number
                            floor_area_sqm:
                              type: number
                            values:
                              type: array
                              description: One value per `months` entry, rounded to $1k.
                              items:
                                type: integer
                            lo:
                              type: integer
                            hi:
                              type: integer
        "400":
          $ref: '#/components/responses/BadRequest'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/nearby:
    get:
      tags: [amenities]
      operationId: getNearby
      summary: Nearby amenities for one building
      description: >
        Precomputed nearby amenities for a postal code: childcare within 1 km
        walking distance, schools within 2 km, plus hawkers, healthcare, carparks
        and supermarkets. Always returns 200; an unknown postal code yields empty
        arrays.
      parameters:
        - name: postal
          in: query
          required: true
          schema:
            type: string
          example: "560123"
      responses:
        "200":
          description: Nearby amenities, each sorted by distance.
          content:
            application/json:
              schema:
                type: object
                required: [childcare, schools, hawkers, healthcare, carparks, supermarkets]
                properties:
                  childcare:
                    type: array
                    items:
                      type: object
                      description: >
                        Fields: centre_code, centre_name, service_model, distance_m,
                        spark_cert, best_vacancy, level_vacancies (array of
                        [level, vacancy] pairs), fees_sc/fees_pr/fees_fg (level →
                        monthly fee maps), lat, lng (Coord), suite, data_date.
                  schools:
                    type: array
                    items:
                      type: object
                      description: >
                        Fields: school_name, level_label, school_category,
                        level_detail, type_code, nature_code, distance_m, sap,
                        autonomous, gifted, mothertongues, cca_names, lat, lng (Coord).
                  hawkers:
                    type: array
                    items:
                      type: object
                      description: >
                        Fields: name, distance_m, lat, lng (Coord), stalls_food,
                        stalls_market, type_code, next_closure_start, next_closure_end.
                  healthcare:
                    type: array
                    items:
                      type: object
                      description: >
                        Fields: category, name, address, building_name, suite,
                        postal_code, distance_m, lat, lng (Coord), tel.
                  carparks:
                    type: array
                    items:
                      type: object
                      description: >
                        Fields: car_park_no, address, distance_m, lat, lng (Coord),
                        car_park_type, parking_system, short_term_parking,
                        free_parking, night_parking, car_park_decks, gantry_height,
                        car_park_basement.
                  supermarkets:
                    type: array
                    items:
                      type: object
                      description: >
                        Fields: licence_num, name, building_name, block_house_num,
                        suite, street_name, postal_code, distance_m, lat, lng (Coord).
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/amenity_markers:
    get:
      tags: [amenities]
      operationId: getAmenityMarkers
      summary: Amenity markers for the map
      description: >
        Flat array of amenity markers, discriminated by the `type` field
        (`hawker`, `childcare`, `school`, `healthcare`, `carpark`, `supermarket`).
        `types` selects which kinds to return (comma-joined; default `childcare`);
        the optional bbox restricts to markers inside it. Every marker carries
        `name` (or an equivalent), `lat`/`lng` (Coord) and, where known,
        `postal_code`; the remaining fields mirror the matching `/api/nearby`
        element minus `distance_m`, plus per-type extras (e.g. childcare contact
        and fee fields, school programme fields, hawker closure dates).
      parameters:
        - name: types
          in: query
          description: Comma-joined marker kinds (default `childcare`).
          schema:
            type: string
          example: "childcare,school,hawker"
        - name: min_lat
          in: query
          schema:
            type: number
            format: double
        - name: max_lat
          in: query
          schema:
            type: number
            format: double
        - name: min_lng
          in: query
          schema:
            type: number
            format: double
        - name: max_lng
          in: query
          schema:
            type: number
            format: double
      responses:
        "200":
          description: Markers of the requested kinds.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [type]
                  properties:
                    type:
                      type: string
                      enum: [hawker, childcare, school, healthcare, carpark, supermarket]
                    lat:
                      $ref: '#/components/schemas/Coord'
                    lng:
                      $ref: '#/components/schemas/Coord'
        "400":
          $ref: '#/components/responses/BadRequest'
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/price_index:
    get:
      tags: [valuation]
      operationId: getPriceIndex
      summary: Quality-adjusted resale price index
      description: >
        The public quality-adjusted price index: a chained national series plus one
        series per HDB town, with a drift-based nowcast tip and the official RPI
        overlay spliced onto the same scale. Pre-serialised at boot; returns `{}`
        before the first server-side build. Final months are chained off pinned
        values and never restated; months past the anchor are provisional. Towns
        under 120 trailing-12-month sales are suppressed (`published: false`,
        empty `points`).
      responses:
        "200":
          description: The full index payload, or `{}` before the first build.
          content:
            application/json:
              schema:
                type: object
                description: Empty object before the first build.
                properties:
                  built_at:
                    type: string
                  base_month:
                    type: string
                    description: First chained month; its value is 100.
                  anchor_month:
                    type: string
                    description: Last month with enough registered sales to be final.
                  latest_data_month:
                    type: string
                  drift_pct_per_year:
                    type: number
                    description: Fitted nowcast drift, annualised percent.
                  national:
                    type: array
                    items:
                      type: object
                      required: [month, value, n, provisional]
                      properties:
                        month:
                          type: string
                        value:
                          type: number
                        n:
                          type: integer
                          description: Sale count behind the month.
                        provisional:
                          type: boolean
                          description: True past the anchor; the value may revise.
                        first_print:
                          type: number
                          description: First published value, kept when a provisional month later revised. Omitted otherwise.
                  nowcast:
                    type: array
                    description: Dashed tip walking the fitted drift forward from the anchor.
                    items:
                      type: object
                      required: [month, value]
                      properties:
                        month:
                          type: string
                        value:
                          type: number
                  rpi:
                    type: array
                    description: Official HDB RPI overlay, spliced onto the chained scale.
                    items:
                      type: object
                      required: [quarter, value]
                      properties:
                        quarter:
                          type: string
                          example: "2024Q3"
                        value:
                          type: number
                  towns:
                    type: array
                    items:
                      type: object
                      required: [town, published, n_recent, points]
                      properties:
                        town:
                          type: string
                        published:
                          type: boolean
                          description: False when the town has under 120 trailing-12-month sales.
                        n_recent:
                          type: integer
                        points:
                          type: array
                          description: Empty when unpublished.
                          items:
                            type: object
                            required: [month, value, lo, hi, n, provisional]
                            properties:
                              month:
                                type: string
                              value:
                                type: number
                              lo:
                                type: number
                              hi:
                                type: number
                              n:
                                type: integer
                              provisional:
                                type: boolean
                              first_print:
                                type: number
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/track_record:
    get:
      tags: [valuation]
      operationId: getTrackRecord
      summary: Public prediction track record
      description: >
        Every frozen model month with its commitment fields (freeze date, SHA-256 of
        the frozen model blob, data month at freeze) and, once that month's sales
        register, the score block (MdAPE, bias, interval coverage, per-liquidity
        buckets, trailing-median baseline on the same sales). Pre-serialised at
        boot; `{"months":[]}` before the first freeze. Frozen rows are never
        rewritten — the published hash is a commitment.
      responses:
        "200":
          description: All frozen months, newest data first.
          content:
            application/json:
              schema:
                type: object
                required: [months]
                properties:
                  months:
                    type: array
                    items:
                      type: object
                      required: [month, frozen_at, model_sha256, data_month_at_freeze,
                                 n_seen_at_freeze]
                      properties:
                        month:
                          type: string
                        frozen_at:
                          type: string
                        model_sha256:
                          type: string
                        data_month_at_freeze:
                          type: string
                          description: Latest data month in the training window at freeze time.
                        n_seen_at_freeze:
                          type: integer
                          description: >
                            Sales of the month already registered (and trained on) at
                            freeze time. Normally 0; nonzero only for the bootstrap
                            month. Those sales are excluded from scoring.
                        score:
                          description: Omitted until the month's sales register.
                          type: object
                          required: [scored_at, final, n_registered, n, mdape, mpe,
                                     coverage, mean_width_pct]
                          properties:
                            scored_at:
                              type: string
                            final:
                              type: boolean
                              description: True once the month is 3+ data months old; scores stop updating.
                            n_registered:
                              type: integer
                              description: Public denominator — every registered sale of the month.
                            n:
                              type: integer
                              description: Sales actually scored.
                            dropped:
                              type: object
                              description: Per-reason drop counts. Omitted when empty.
                              additionalProperties:
                                type: integer
                            mdape:
                              type: number
                            mpe:
                              type: number
                            coverage:
                              type: number
                            mean_width_pct:
                              type: number
                            liquidity:
                              type: array
                              description: Coverage by block-liquidity bucket. Omitted when empty.
                              items:
                                type: object
                                required: [bucket, n, coverage, mean_width_pct]
                                properties:
                                  bucket:
                                    type: string
                                  n:
                                    type: integer
                                  coverage:
                                    type: number
                                  mean_width_pct:
                                    type: number
                            baseline_n:
                              type: integer
                              description: Omitted pre-baseline.
                            baseline_mdape:
                              type: number
                              description: Trailing-median baseline on the same sales. Omitted pre-baseline.
                            model_mdape_on_baseline:
                              type: number
                              description: Model MdAPE restricted to the baseline-eligible sales. Omitted pre-baseline.
                            note:
                              type: string
                              description: Omitted when absent.
        "500":
          $ref: '#/components/responses/InternalServerError'

  /api/track_record_detail:
    get:
      tags: [valuation]
      operationId: getTrackRecordDetail
      summary: Per-sale scored rows for one track-record month
      description: >
        The individual scored sales behind one month's track-record summary, served
        from the boot-time load. 404 when the month is unknown or not yet scored.
      parameters:
        - name: month
          in: query
          required: true
          schema:
            type: string
            pattern: '^\d{4}-\d{2}$'
          example: "2026-05"
      responses:
        "200":
          description: Scored sales for the month.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required: [block, street, flat_type, storey_range, floor_area_sqm,
                             predicted, lo, hi, actual, bucket, via]
                  properties:
                    block:
                      type: string
                    street:
                      type: string
                    flat_type:
                      type: string
                    storey_range:
                      type: string
                    floor_area_sqm:
                      type: number
                    predicted:
                      type: number
                    lo:
                      type: number
                    hi:
                      type: number
                    actual:
                      type: number
                    bucket:
                      type: string
                      description: Block-liquidity bucket.
                    via:
                      type: string
                      description: Which fixed-effect level served the prediction (street/town/global).
        "404":
          description: No scored sales for that month.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        "500":
          $ref: '#/components/responses/InternalServerError'
