asyncapi: "3.0.0"
info:
  title: MinuteTemp WebSocket API
  version: "1.30.0"
  description: |
    Real-time weather observations, forecast updates, and prediction market updates
    (without prices) pushed via WebSocket.

    This document covers API-key WebSocket channels:
    `/ws/api/1m` and `/ws/api/5m`.

    It also summarizes the separate product UI stream at `/ws/ui` (browser session ticket,
    not an API key).

    **Observation cadence:** All WebSocket streams carry **1-minute** observation events.
    Free UI users on `/ws/ui` receive the same 1-minute payloads on a **3-minute delivery delay**.
    Paid UI and paid API streams are realtime (no delay).

    **Chart files on `/ws/ui`:** when a station's chart file is rewritten, `/ws/ui` subscribers of
    its city get `{"type":"chart_updated","station":"<code>","generation":<int>,"path":"<path>"}`:
    paid users for the private file, free users for the public file after the 3-minute delay.
    `path` names that generation's immutable copy of the file on the data host (absent when the
    server names none). The API-key channels do not carry this event.

    **Weather map stations on `/ws/ui`:** `map_stations` on `subscribe`/`unsubscribe` takes ICAO
    codes, station by station, for the stations a weather map shows (its market stations and the
    observing stations around it, including observation-only neighbours). They carry
    `observation` events only, at most 100 per connection (per-user limits across connections
    are planned, issue #381), and do not use the free tier's three
    city and index slots. Free users get them after the 3-minute delay without report-derived
    observations, and only for the stations free users may follow: every daily market's
    settlement stations and the members of the hourly indexes without an hourly market. Other
    stations are rejected (`scope: map_station`, `reason: paid plan required`), and a station that
    stops being free (its index gains an hourly market) is no longer delivered to free
    connections. See `map_stations` on the subscribe message.

    **Hourly index files:** `hourly_indexes` subscriptions on every channel also get
    `index_file_updated` when the city's current hourly index data file is rewritten (see
    "Hourly Index Data Files" below).

    **Station reports on `/ws/ui`:** reports need a paid plan (UI and above, trials included).
    Free UI users get only CLI/DSM `station_report` events, reduced to `locked: true`,
    `report_type`, `report_date`, `issuance_time`, `fetched_at`, `provider` and the
    `max_temp_*`/`min_temp_*` high/low and `official` (no report ids, revisions, text or
    source URLs), and no METAR reports.

    **v1.6 breaking change:** Price updates are now delivered on the unified
    `/ws/api/1m` and `/ws/api/5m` channels. The separate `/ws/api/prices/*`
    endpoints have been removed.

    ## Connection Flow
    1. Obtain a ticket: `POST /api/v1/ws-ticket` with your API key.
    2. Connect to a channel.
    3. Pass the ticket as subprotocols: `Sec-WebSocket-Protocol: bearer, <ticket>`.
    4. Send `subscribe` messages to choose cities and/or stations.
    5. Receive real-time events (observations, forecasts, market updates without prices, reports, weather events).

    ```bash
    # Get ticket
    TICKET=$(curl -s -X POST -H "X-API-Key: your_key" \
      https://api.minutetemp.com/api/v1/ws-ticket | jq -r '.data.ticket')

    # Connect to unified stream (weather + market updates)
    wscat -c wss://api.minutetemp.com/ws/api/1m -s bearer -s "$TICKET"

    # Subscribe to cities (all stations interleaved)
    {"type": "subscribe", "cities": ["chi", "nyc"]}

    # Opt in to day-of oracle score payloads; omitted defaults to overall/day_ahead
    {"type": "subscribe", "cities": ["chi"], "oracle_score_modes": ["overall", "day_ahead", "day_of"]}

    # Subscribe to specific eligible stations (ICAO codes)
    {"type": "subscribe", "stations": ["KORD", "KFLL"]}

    # Mix both in one message
    {"type": "subscribe", "cities": ["chi"], "stations": ["KLGA"]}

    # Subscribe to cities and stations in one message
    {"type": "subscribe", "cities": ["chi"], "stations": ["KORD"]}
    ```

    ## Direct Station Subscriptions
    Direct `stations` subscriptions accept case-insensitive ICAO codes for active cityless stations
    or active stations whose resolved parent city is active. Codes are normalized to uppercase.
    Events for a cityless station carry its ICAO `station_id`, set `slug` to the empty string,
    and omit `city_sequence`; the normal global `sequence` is still present.

    Subscription support does not enable data collection. Observation, report, weather-event,
    forecast, oracle-score, and high/low availability depends on the station's configured
    producers and data that already exists. Price updates remain city scoped: a station
    subscription receives them only when the station belongs to a real market-linked city and
    the price's market source is enabled for that station. A cityless station never receives
    price updates merely because it can be subscribed to directly.

    ## Endpoints
    - `/ws/api/1m` — Unified stream at 1-minute cadence (Starter/Pro/Clanker plans)
    - `/ws/api/5m` — Legacy compatibility endpoint with the same unthrottled 1-minute cadence as `/ws/api/1m` (Starter/Pro/Clanker plans)
    - `/ws/ui` — Product UI stream (paid: realtime 1-minute; free: 1-minute data delivered after a 3-minute delay). Ticket via `/ui/v1/ws-ticket`.

    ## Bot Startup Recipe
    1. Bootstrap baseline data via REST cursor endpoints.
    2. Connect WebSocket and subscribe.
    3. Wait for `snapshot_complete` before entering steady-state processing.
    4. Track `sequence`; if a gap is detected, replay via REST and continue.

    ## Forecast Data Files
    `forecast_updated` is the trigger to fetch a new run: its `path` is the run's forecast data
    file, read with a token from `GET /api/v1/data/access`. See "Getting forecast data" in the
    [API reference](/docs/api) for the full flow, history and error handling.

    ## Hourly Indexes
    Subscribe to Kalshi Weather Index cities with `hourly_indexes` (index city IDs from
    `GET /api/v1/hourly-indexes`, e.g. `miami`, `nyc`). Each city costs **1 subscription slot**
    and carries every event for that city: `index_minute`, `hour_update`, `hour_settled`,
    `hour_reconciled`, `calibration`, `hour_event`, `market_update` (at most 1/s) and
    `forecast_index` (about hourly: the index forecast from the members' HRRR 15-minute
    temperatures, also the snapshot's `forecast`) and `forecast_bias` (the forecast's bias after
    each new or revised valued minute). On
    `/ws/api/*` it also delivers the member stations' `observation` events and the city's
    forecast station `forecast_updated` and `oracle_scores_updated` events, at no extra slot
    cost and at most once per connection.

    ```json
    {"type": "subscribe", "hourly_indexes": ["miami", "nyc"]}
    {"type": "resume", "hourly_indexes": [{"city": "miami", "after_seq": 48213}]}
    ```

    Every hourly index event carries `type`, `event_id`, `emitted_at`, `city` and a per-city
    `seq` assigned by the collector (the same on every API instance). The one exception is
    `index_file_updated`, which tells you a city's index data file changed: it carries `type`,
    `city`, `generation`, `path` and `as_of`, and no `event_id`, `emitted_at` or `seq`, so it
    never counts as a `seq` gap. Ignore any message whose `type` you do not know before reading
    `event_id` or checking `seq` (see Compatibility). After `subscribed`, each
    accepted city gets an `hourly_index_snapshot` (the `GET /api/v1/hourly-indexes/{city}` body
    plus the last 75 minutes with member readings), then its live events with `seq` greater than
    the snapshot's, in order. Re-subscribing an already subscribed city sends a fresh snapshot
    without taking another slot.

    Bot recipe: apply the snapshot, then apply events in `seq` order; key minutes by
    `(city, minute)` and keep the highest `revision` (`final` minutes never go back to
    provisional). On a `seq` gap (for example after a reconnect) send `resume` with the last
    applied seq: the server replays the logged events after it (about the last 2 hours) and
    sends `resume_complete`. On `resume_failed`, subscribe again and replace your state from the
    new snapshot. Use REST only for history and cold starts.

    Bias-adjusted forecast recipe: cache the snapshot's `forecast` and replace it on each
    `forecast_index`; on `forecast_bias`, set `bias_f`, `bias_as_of_minute` and `member_bias` and
    recompute `adjusted_f[i] = value_f[i] + bias_f` (null where either is null) and each settle's
    `adjusted_value_f = value_f + bias_f`. Both events carry the city `seq`, so a gap is repaired
    with `resume` like any other event. `hour_update` carries the same bias as
    `forecast_bias_f`.

    Forecast models: the top-level forecast fields of the snapshot's `forecast`, of
    `forecast_index` and of `forecast_bias` are always the HRRR forecast. Where RRFS is enabled
    for the index, its forecast travels beside them, keyed by model ID
    (`ncep_rrfs_conus_15min`): the snapshot's `forecasts`, `forecast_index.forecasts` (replace
    your cached forecast of each model present; a model not listed is unchanged) and
    `forecast_bias.biases` (apply each entry to that model's cached forecast as above). The two
    are separate forecasts with separate biases, measured against the same index minute; neither
    stands in for the other, and a city without an RRFS forecast has no entry. A
    `forecast_index` sent for an RRFS refresh repeats the current HRRR forecast in its
    top-level fields, so a consumer that reads only those needs no change. `hour_update`'s
    forecast fields stay HRRR.

    An RRFS forecast is removed when none of its steps is left (no new run arrived) or its
    members no longer have RRFS forecasts: the snapshot then has no entry for the model, and
    connected consumers get a `forecast_index` whose `forecasts` entry for it has no steps
    (every array `[]`). Treat an entry without steps as "this model has no forecast now" and
    drop your cached one; a snapshot is authoritative for which models have a forecast. The
    HRRR forecast is not removed this way.

    ## Hourly Index Data Files
    Each index city's forecast (HRRR and RRFS 15-minute, the stitched past and the live curve) is
    also published as a JSON data file on the data host, read with a token from
    `GET /api/v1/data/access`. See "Hourly index data files" in the [API reference](/docs/api) for
    the format, history files, tokens and error handling.

    When a city's current file is rewritten, its `hourly_indexes` subscribers get:

    ```json
    {"type": "index_file_updated", "city": "chicago", "generation": 88123,
     "path": "/private/v1/hourly-indexes/chicago.json", "as_of": "2026-10-14T17:15:00Z"}
    ```

    - It carries **no `seq`**, is not in the replay log and is not replayed by `resume`. Do not
      count it in seq-gap detection; a client that does not know it ignores it (see
      Compatibility).
    - `generation` orders files of a city: fetch `path` and keep a file only when its
      `generation` is above the one you hold. The CDN may serve the previous file for up to 60
      seconds after a rewrite: when the file you get is older than the event, read it once more
      62 seconds later. Events are not repeated; after a reconnect the `hourly_index_snapshot`
      names the current file in `file`.
    - `/ws/api/*` and paid `/ws/ui` subscribers get the private file
      (`/private/v1/hourly-indexes/{city}.json`, needs the `hourly_indexes` token); free `/ws/ui`
      subscribers get the public copy of index-only cities (`/public/v1/hourly-indexes/{city}.json`,
      no token), with no delay.
    - The socket stays authoritative for the live forecast and its bias (`forecast_index`,
      `forecast_bias`); the file is authoritative for the stitched past. Use a file's `live`
      only for a model the socket has not delivered, never let it undo a newer socket bias or
      bring back a model the socket removed, and ignore a file whose `as_of` is more than an hour
      old.

    Index-only cities (no Kalshi hourly market yet) use the same contract with
    `hourly_series_ticker: null`, an empty `ladder` and no `hour_event`/`market_update`/
    `hour_reconciled` events until a series is attached. Hourly index data is never delayed:
    market cities need a paid plan on `/ws/ui` (UI and above, trials included; the API channels
    already require Starter), and index-only cities are open to every `/ws/ui` user in realtime,
    with `resume`. When a series is attached to an index-only city, `/ws/ui` subscriptions
    without a paid plan get
    `{"type": "subscription_revoked", "scope": "hourly_index", "value": "<city>", "reason": "insufficient scope"}`
    on the city's next event and receive nothing more for it (API channels are Starter+ and are
    never revoked).

    ## Changelog
    - v1.30:
      - Live CLI/DSM `station_report` events (paid and locked) add `official`: the report
        date's official high and low with this report counted. `high`/`low` are NWS's current
        word (the newest full-day CLI), `settlement_preview` the first full-day CLI, corrected
        only by a marked correction (what Kalshi settles on). Before the full-day CLI both are
        the latest in-day CLI; a side without any CLI value is the latest DSM's (null when no
        report has one). `as_of` (microseconds) is when the newest report it counted was stored
        and `report_count` how many CLI/DSM reports it counted: keep the day with the later
        `as_of`, on equal `as_of` the one with the larger `report_count`, and on a full tie the
        one you hold. Bootstrap reports do not carry it.
    - v1.29:
      - The API channels (`/ws/api/1m`, `/ws/api/5m`) no longer carry market prices, for any
        source. Every `price_update` is still sent, with the same `sequence` and `city_sequence`
        as before, but its markets omit `yes_price`, `no_price`, `yes_bid`, `yes_ask`, `no_bid`,
        `no_ask` and `volume`; ForecastEx updates carry `price_display: false` and omit
        `open_interest`, `vwap`, `settlement_price`, `audit_status` and `snapshot_time`. Hourly
        index `market_update` events, the `ladder` of `hourly_index_snapshot` and replays after
        `resume` keep every strike and `seq` with the quote fields (`yes_bid`, `yes_ask`,
        `no_bid`, `no_ask`, `last_price`, `volume`) null. These fields stay in the schema,
        deprecated. Behavior change.
    - v1.28:
      - ForecastEx price display is now enabled by default: ForecastEx `price_update` events
        carry `price_display: true` and `yes_price`, `no_price` (last traded prices, not a bid
        or ask), `open_interest`, `volume`, `vwap`, `settlement_price`, `audit_status` and
        `snapshot_time`. MinuteTemp can still turn price display off (`price_display: false`,
        those fields absent). Behavior change.
    - v1.27:
      - `price_update` adds `source: forecastex`: the daily high and low contracts of
        ForecastEx, which Robinhood and IBKR broker, for stations that enable Robinhood or IBKR.
        Their markets carry the contract's strike, expiry and status; their prices only while
        ForecastEx price display is enabled. Additive.
    - v1.26:
      - Added `map_stations` on `/ws/ui` `subscribe`/`unsubscribe` (weather map stations,
        observations only), `accepted.map_stations` on `subscribed` and the `map_station`
        rejection scope. Additive; the API channels reject `map_stations`. Free connections may
        subscribe only to free stations (daily market settlement stations and members of hourly
        indexes without an hourly market); others are rejected with `paid plan required`. At
        most 100 map stations per connection.
    - v1.25:
      - Documented the entitlement close: an open `/ws/api/*` or paid `/ws/ui` connection is
        closed with code 1008 when the plan no longer includes the channel, the API key that
        minted the ticket is revoked, or the connection reaches its maximum age (8 to 10 hours).
        A renewal or upgrade keeps the connection open. See "Entitlement Close".
    - v1.24:
      - `chart_updated` on `/ws/ui` adds `path`: the data host path of the announced
        generation's copy of the chart file, which never changes, so reading it gets that
        generation however old the edge's copy of the station's file is. Additive; absent when
        the server names none. The chart file's format is not part of this API.
    - v1.23:
      - Observation-only (neighbour) stations have their own per-user subscription quota on
        `/ws/api/*`: Starter 30, Pro 300, Clanker unlimited. They no longer count against the
        market quota (cities, market stations and hourly indexes: Starter 6, Pro 60, Clanker 200,
        unchanged). Subscriptions stay station by station; the server picks the quota per code.
      - A full quota rejects with a reason naming it and its limit, e.g.
        `market subscription limit exceeded (limit 6)` or
        `neighbour subscription limit exceeded (limit 30)`. Within the `stations` list, the
        newly requested market stations are accepted or rejected together, and so are the newly
        requested neighbours. Fields of one `subscribe` are checked in turn (`cities`,
        `stations`, `hourly_indexes`), so one field can be accepted and a later one rejected.
    - v1.22:
      - Added `index_file_updated` on `hourly_indexes` subscriptions (every channel): the city's
        current hourly index data file was rewritten. Unsequenced: no `seq`, not logged, not
        replayed by `resume`; per-city `seq` order and gap detection are unchanged. The
        `hourly_index_snapshot` adds `file` (`generation`, `path`, `as_of`), omitted while no file
        is published. Both are additive. See "Hourly Index Data Files".
    - v1.21:
      - `subscribed.accepted` now includes `oracle_score_modes`: the oracle score modes active
        for the connection after applying the default and dropping unknown modes. Additive;
        clients that do not read it are unaffected.
    - v1.20:
      - One `observation` event per observation update: the collector sends each update once,
        straight from its live path, and no longer follows it with a committed confirmation.
        `persistence_status` is omitted on `observation` events and on the `new_high`/`new_low`
        alerts they raise; those events update public delayed feeds and daily high/low caches
        directly. Synoptic stations send a non-preliminary update as soon as a temperature
        arrives, with the latest other sensor values received.
    - v1.19:
      - RRFS next to HRRR in the hourly index forecast (additive): the snapshot adds `forecasts`,
        `forecast_index` adds `forecasts` and `forecast_bias` adds `biases`, each keyed by model
        ID (`ncep_rrfs_conus_15min`). Every existing field stays the HRRR forecast and bias. No
        new event type. A `forecasts` entry without steps removes that model's forecast. See
        "Forecast models" above.
      - `index_minute` (and snapshot minutes) add `restated_f` and `restated_at`: a Kalshi
        restatement no longer replaces `official_f`/`official_at`, which keep the first official
        value.
      - `hour_settled` / `hour_reconciled` (and the snapshot's `recent_settlements`) add the status
        `incomplete_data` with `missing_minutes`, and `settle_source` (`computed` or
        `kalshi_expiration`: an `incomplete_data` market hour settled from Kalshi's final value,
        with `match` null).
      - `resume_failed` adds the reason `seq_gap`: the replay log is missing a seq after
        `after_seq`, so the client must resubscribe for a snapshot. Hourly index seqs keep rising
        across Redis primary changes and are never reused; a failover can skip seqs, which
        clients see and repair as a gap.
      - `hour_update` (and the snapshot's `current_hour`) carries `feed_conditions`: degradations
        of the city's index feed over its last 30 final minutes (METAR fallback, exclusion, QC
        substitution, quorum risk, unavailable minutes). A change in conditions publishes a new
        `hour_update`. The snapshot adds `contract_terms_url`.
      - Hourly index events are never delayed: free `/ws/ui` users get index-only cities in
        realtime (live snapshot, `resume` supported), so `resume_failed` no longer uses
        `delayed_feed`. A `/ws/ui` subscription without a paid plan to a city that gains a Kalshi
        hourly series is ended with `subscription_revoked`.
      - Market cities on `/ws/ui` open to every paid plan (UI and above, trials included), not
        only Starter and above. API channel requirements are unchanged.
      - `hour_update` / `current_hour` place values against the ladder: `settles_now_as` adds
        `implied_floor_strike`, `nearest_strike_f`, `distance_to_strike_f` and `strike_side`; the
        adjusted forecast settle adds `forecast_adjusted_nearest_strike_f`,
        `forecast_adjusted_distance_to_strike_f` and `forecast_adjusted_strike_side` (null for
        index-only cities).
      - Added the hourly index `forecast_bias` event (per-city `seq`, logged for `resume`) and the forecast's `bias_f`, `bias_as_of_minute`, `adjusted_f` and
        `member_bias` fields (on `forecast_index` and the snapshot's `forecast`); each forecast
        settle adds `adjusted_value_f` and `adjusted_floor_strike`.
      - Removed `trend_settle_f` and `trend_floor_strike` from `hour_update` and the snapshot's
        `current_hour` (the 15-minute trend line); use `forecast_adjusted_settle_f`.
      - Member readings in `index_minute` (and snapshot minutes) carry per-station metrics:
        `pull_f` (leave-one-out pull on the index), `change_5m_f`, `hour_high_f` and `hour_low_f`.
    - v1.18:
      - `hour_update` (and the snapshot's `current_hour`) carries the bias-adjusted HRRR forecast
        settle: `forecast_settle_f`, `forecast_bias_f` (newest valued minute minus the forecast at
        that minute), `forecast_adjusted_settle_f` and `forecast_adjusted_floor_strike`; null
        without a forecast covering the hour, a valued minute, or quorum.
    - v1.17:
      - Added the hourly index `forecast_index` event and the snapshot's `forecast` field: the
        index forecast from the members' HRRR 15-minute temperatures, with per-member series,
        the next top-of-hour forecast settles and implied strikes.
    - v1.16:
      - Added hourly index subscriptions (`hourly_indexes` on `subscribe`/`unsubscribe`), `resume`,
        and the `hourly_index_snapshot`, `index_minute`, `hour_update`, `hour_settled`,
        `hour_reconciled`, `calibration`, `hour_event`, `market_update`, `resume_complete` and
        `resume_failed` messages.
    - v1.15:
      - Added direct subscriptions for active stations without a city, using an empty `slug` and no `city_sequence` on their station events.
      - Clarified that station event availability is producer/data dependent and station price delivery requires a real market-linked city and matching market source.
    - v1.14:
      - Documented `/ws/ui` observation delivery: 1-minute payloads for all tiers; free UI receives a 3-minute delay.
      - Clarified that `/ws/api/5m` is a legacy path name, not a 5-minute aggregation tier.
    - v1.13:
      - `/ws/api/5m` remains available for backwards compatibility and now delivers the same unthrottled event cadence as `/ws/api/1m`.
    - v1.12:
      - Added optional `oracle_score_modes` to `subscribe`. Legacy clients that omit it receive only `overall` and `day_ahead` oracle score modes. Include `day_of` to opt in to same-day forecast scoring payloads.
    - v1.11:
      - Added `temperature_day_mode` and `temperature_day_date` on `observation`, `new_high`, and `new_low` events.
      - Added `wu_day_mode` and `wu_day_date` on `observation` events.
    - v1.10:
      - `oracle_scores_updated` now includes optional `overall`, `day_ahead`, and `day_of` objects matching `GET /api/v1/stations/{station_id}/oracle-scores` `data` shape (same plan tier and defaults as the connection). `updated_at` may be omitted on subscribe bootstrap snapshots.
      - Oracle events are delivered **per connection** (tier-scoped) and are not cadence-throttled.
    - v1.9:
      - Added `weather_event` delivery on `/ws/api/1m` and `/ws/api/5m` for subscribed cities and stations.
    - v1.8:
      - Added Weather Underground observation fields on `observation` events (`wu_current_temp_*`, `wu_daily_high_*`, `wu_daily_low_*`, `wu_observation_time`, `wu_fetched_at`).
      - Clarified that `observation` events can be WU-only updates with `temperature_f`/`temperature_c` omitted.
    - v1.7:
      - Simplified rate limiting to two limits: max connections per tier and max subscriptions per connection.
      - Removed cross-connection Redis station tracking; subscription limits are now per-connection only.
      - Cities and stations share a single combined quota per `/ws/api/*` connection (Starter 3, Pro 10, Clanker 50).
      - Removed subscription message rate limiting.
      - Removed global public connection cap; per-IP limit (10 connections) via Redis is sufficient.
    - v1.6:
      - Merged price channels back into unified `/ws/api/1m` and `/ws/api/5m`.
      - Removed `/ws/api/prices/1m` and `/ws/api/prices/5m`.
      - Added Redis-backed cross-connection subscription limits (stations per tier).
      - Added station-level subscription limits per tier.
      - Removed forecast item subscriptions; forecast updates now delivered to city/station subscribers automatically.
    - v1.5:
      - Split API streams by domain (reverted in v1.6).
      - UI stream `/ws/ui` remains unchanged.
    - v1.4:
      - Added station-level subscriptions via `stations` array on subscribe/unsubscribe.
      - Station subscriptions available on `/ws/api/1m` and `/ws/api/5m` only.
      - Subscribed ack now includes `city_details` with station lists per city.
      - Station subscribers receive filtered `price_update` based on station market-source flags.
      - Subscribe ordering: `subscribed` ack first, then bootstrap payloads, then `snapshot_complete`.
      - Per-connection at-most-once delivery when city and station scopes overlap.
    - v1.3:
      - Added `station_report` event for CLI/DSM/METAR report delivery.
      - Canonicalized `observation` payload to use `temperature_f` and `temperature_c`.
      - Added report provenance fields on `observation`, `new_high`, and `new_low` events.
      - Canonicalized extreme payload fields to `value_f`, `value_c`, and `prev_value_f`.
    - v1.2:
      - Added `preliminary` on `observation` events.
      - Observations may now arrive in two phases: preliminary update, then final flush update.
    - v1.1:
      - Added envelope metadata on server events: `event_id`, `sequence`, `emitted_at`.
      - Added `subscribed` acknowledgement and `snapshot_complete` marker.

    ## Connection & Subscription Limits
    - Market subscription limit across all `/ws/api/*` connections per user (cities + market stations + hourly indexes combined): Starter 6, Pro 60, Clanker 200
    - Neighbour subscription limit across all `/ws/api/*` connections per user, for observation-only stations (weather-map and nowcast neighbours, not listed by `GET /api/v1/stations`): Starter 30, Pro 300, Clanker unlimited. Neighbours do not count against the market limit.
    - A full limit rejects the subscription with a reason naming the quota and its limit, e.g. `market subscription limit exceeded (limit 6)` or `neighbour subscription limit exceeded (limit 30)`
    - No per-user connection limit for paid tiers. Connections are unlimited; subscriptions are the enforced resource.
    - Product UI (`/ws/ui`): paid users receive realtime 1-minute events; free users receive 1-minute events delayed by 3 minutes
    - Public UI connections (`/ws/ui` with public scope): 10 per IP, 3 subscriptions per connection (cities and hourly indexes, no stations)
    - Weather map stations (`map_stations`, `/ws/ui` only): 100 per connection on every tier (the largest map view has 83), separate from the limits above. Per-user limits across connections are planned (issue #381)
    - No server-side cap is applied to outbound market-data message volume on active authenticated `/ws/api/*` connections.

    ## Heartbeat
    Server sends WebSocket ping frames every ~54 seconds. Client must respond with pong.
    Connections are closed after 60 seconds without a pong.

    ## Entitlement Close
    `/ws/api/*` connections and paid `/ws/ui` connections are re-checked against the plan and,
    for `/ws/api/*`, the API key that minted the ticket, about every 5-6 minutes. A past-due
    grace period is treated as ending 90 seconds after its end time, so a renewal that lands
    near the boundary keeps the connection. When the plan no longer includes the channel
    (cancellation at period end, a past-due grace period ending, a trial ending, a plan change)
    or the key is revoked, the server sends a close frame with code `1008` (policy violation)
    and one of these reasons, then closes the connection:
    - `paid access expired`: the plan no longer includes this channel.
    - `api key revoked`: the API key that minted the ticket was revoked.
    - `reauthenticate`: the connection reached its maximum age, 8 to 10 hours. A `/ws/api/*`
      connection opened with a ticket that has no `api_key_id` (issued before v1.25) gets 10 to
      15 minutes. Reconnect with a new ticket.

    A renewal or upgrade before the check keeps the connection open, and a failed check never
    closes it. On a `1008` close, request a new ticket with a valid key before reconnecting;
    `POST /api/v1/ws-ticket` returns `403` when the plan has no WebSocket access and `401` for a
    revoked key, so do not retry those in a loop. Spread reconnects with jittered backoff. On
    `/ws/ui`, a paid connection is closed the same way and the client reconnects to the stream
    its plan allows; free `/ws/ui` connections are not re-checked and have no maximum age.

    ## Compatibility
    This document describes the current canonical payload shapes.
    `sequence` is monotonic per API instance. Use `event_id` for deduplication
    across reconnects or load-balanced instance changes.

    New message types and fields may be added within a major version. Ignore a message whose
    `type` you do not know, before any `seq` or `sequence` check: an unknown type is never a gap.
    `index_file_updated` (v1.22) is such an addition and carries no hourly index `seq`.

servers:
  production:
    host: api.minutetemp.com
    protocol: wss
    description: Production WebSocket server

channels:
  api1m:
    address: /ws/api/1m
    description: 1-minute cadence unified channel (Starter/Pro/Clanker plans). Weather observations, forecasts, reports, weather events, and market updates without prices. No throttling.
    messages:
      subscribe:
        $ref: "#/components/messages/WeatherSubscribe"
      unsubscribe:
        $ref: "#/components/messages/WeatherUnsubscribe"
      subscribed:
        $ref: "#/components/messages/Subscribed"
      snapshotComplete:
        $ref: "#/components/messages/SnapshotComplete"
      observation:
        $ref: "#/components/messages/ObservationEvent"
      stationReport:
        $ref: "#/components/messages/StationReportEvent"
      weatherEvent:
        $ref: "#/components/messages/WeatherEventEvent"
      forecastUpdated:
        $ref: "#/components/messages/ForecastUpdatedEvent"
      oracleScoresUpdated:
        $ref: "#/components/messages/OracleScoresUpdatedEvent"
      forecastVersions:
        $ref: "#/components/messages/ForecastVersionsEvent"
      newHigh:
        $ref: "#/components/messages/NewHighEvent"
      newLow:
        $ref: "#/components/messages/NewLowEvent"
      priceUpdate:
        $ref: "#/components/messages/PriceUpdateEvent"
      error:
        $ref: "#/components/messages/ErrorEvent"
      hourlyIndexResume:
        $ref: "#/components/messages/HourlyIndexResume"
      hourlyIndexSnapshot:
        $ref: "#/components/messages/HourlyIndexSnapshotEvent"
      indexMinute:
        $ref: "#/components/messages/IndexMinuteEvent"
      hourUpdate:
        $ref: "#/components/messages/HourUpdateEvent"
      hourSettled:
        $ref: "#/components/messages/HourSettledEvent"
      hourReconciled:
        $ref: "#/components/messages/HourReconciledEvent"
      calibration:
        $ref: "#/components/messages/CalibrationEvent"
      hourEvent:
        $ref: "#/components/messages/HourEventEvent"
      marketUpdate:
        $ref: "#/components/messages/MarketUpdateEvent"
      forecastIndex:
        $ref: "#/components/messages/ForecastIndexEvent"
      forecastBias:
        $ref: "#/components/messages/ForecastBiasEvent"
      indexFileUpdated:
        $ref: "#/components/messages/HourlyIndexFileUpdatedEvent"
      resumeComplete:
        $ref: "#/components/messages/ResumeCompleteEvent"
      resumeFailed:
        $ref: "#/components/messages/ResumeFailedEvent"

  api5m:
    address: /ws/api/5m
    description: |
      Legacy compatibility unified channel (Starter/Pro/Clanker plans). Weather observations, forecasts, reports, weather events, and market updates without prices.
      This endpoint keeps the `/ws/api/5m` path and `ws:api:5m` scope for existing clients, but now delivers the same unthrottled event cadence as `/ws/api/1m`.
    messages:
      subscribe:
        $ref: "#/components/messages/WeatherSubscribe"
      unsubscribe:
        $ref: "#/components/messages/WeatherUnsubscribe"
      subscribed:
        $ref: "#/components/messages/Subscribed"
      snapshotComplete:
        $ref: "#/components/messages/SnapshotComplete"
      observation:
        $ref: "#/components/messages/ObservationEvent"
      stationReport:
        $ref: "#/components/messages/StationReportEvent"
      weatherEvent:
        $ref: "#/components/messages/WeatherEventEvent"
      forecastUpdated:
        $ref: "#/components/messages/ForecastUpdatedEvent"
      oracleScoresUpdated:
        $ref: "#/components/messages/OracleScoresUpdatedEvent"
      forecastVersions:
        $ref: "#/components/messages/ForecastVersionsEvent"
      newHigh:
        $ref: "#/components/messages/NewHighEvent"
      newLow:
        $ref: "#/components/messages/NewLowEvent"
      priceUpdate:
        $ref: "#/components/messages/PriceUpdateEvent"
      error:
        $ref: "#/components/messages/ErrorEvent"
      hourlyIndexResume:
        $ref: "#/components/messages/HourlyIndexResume"
      hourlyIndexSnapshot:
        $ref: "#/components/messages/HourlyIndexSnapshotEvent"
      indexMinute:
        $ref: "#/components/messages/IndexMinuteEvent"
      hourUpdate:
        $ref: "#/components/messages/HourUpdateEvent"
      hourSettled:
        $ref: "#/components/messages/HourSettledEvent"
      hourReconciled:
        $ref: "#/components/messages/HourReconciledEvent"
      calibration:
        $ref: "#/components/messages/CalibrationEvent"
      hourEvent:
        $ref: "#/components/messages/HourEventEvent"
      marketUpdate:
        $ref: "#/components/messages/MarketUpdateEvent"
      forecastIndex:
        $ref: "#/components/messages/ForecastIndexEvent"
      forecastBias:
        $ref: "#/components/messages/ForecastBiasEvent"
      indexFileUpdated:
        $ref: "#/components/messages/HourlyIndexFileUpdatedEvent"
      resumeComplete:
        $ref: "#/components/messages/ResumeCompleteEvent"
      resumeFailed:
        $ref: "#/components/messages/ResumeFailedEvent"

operations:
  subscribe:
    action: send
    channel:
      $ref: "#/channels/api1m"
    summary: Subscribe to updates by city or station
    description: |
      Applies to `/ws/api/1m` (Starter/Pro/Clanker) and `/ws/api/5m` (Starter/Pro/Clanker).
      City subscriptions deliver their existing event families. Direct station subscriptions use
      ICAO codes and deliver available station-scoped events. Eligible stations are active cityless
      stations or active stations whose resolved parent city is active; producer/data availability
      and the separate price rules still apply.
    messages:
      - $ref: "#/channels/api1m/messages/subscribe"

  unsubscribe:
    action: send
    channel:
      $ref: "#/channels/api1m"
    summary: Unsubscribe from updates
    description: Applies to `/ws/api/1m` and `/ws/api/5m`.
    messages:
      - $ref: "#/channels/api1m/messages/unsubscribe"

  receiveSubscribed:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive subscription acknowledgement
    description: Sent after processing a subscribe request with accepted and rejected subscriptions.
    messages:
      - $ref: "#/channels/api1m/messages/subscribed"

  receiveSnapshotComplete:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive bootstrap completion marker
    description: Sent after the initial bootstrap payload for a subscribe request has completed.
    messages:
      - $ref: "#/channels/api1m/messages/snapshotComplete"

  receiveObservation:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive real-time weather observation
    description: |
      Sent when an observation update is received for a subscribed city or station.
      Delivered on both `/ws/api/1m` and `/ws/api/5m` channels.

      A timestamp can receive more than one update when its value changes (for example a
      corrected temperature or a merged weather field). Each update is sent once.
    messages:
      - $ref: "#/channels/api1m/messages/observation"

  receiveStationReport:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive official station report publication
    description: |
      Sent when a new CLI/DSM/METAR report is ingested for a subscribed city or station.
      Delivered on both `/ws/api/1m` and `/ws/api/5m` channels.
    messages:
      - $ref: "#/channels/api1m/messages/stationReport"

  receiveWeatherEvent:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive weather event lifecycle update
    description: |
      Sent when a subscribed city or station enters, refreshes, or exits a notable weather event state.
      Delivered on both `/ws/api/1m` and `/ws/api/5m` channels.
    messages:
      - $ref: "#/channels/api1m/messages/weatherEvent"

  receivePriceUpdate:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive market update (no prices)
    description: |
      Sent when the markets of a subscribed city or station change; it carries no prices.
      Station subscriptions receive updates only for sources enabled on that station.
      Delivered on `/ws/api/1m` and `/ws/api/5m` channels.
    messages:
      - $ref: "#/channels/api1m/messages/priceUpdate"

  receiveForecastUpdate:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive forecast cache invalidation notification
    description: |
      Sent when a new forecast run is available for a subscribed city or station.
      Delivered on both `/ws/api/1m` and `/ws/api/5m` channels.
    messages:
      - $ref: "#/channels/api1m/messages/forecastUpdated"

  receiveOracleScoresUpdated:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive oracle score update (payload + invalidation)
    description: |
      Sent when oracle scores change for a subscribed city or station, and after subscribe as a bootstrap snapshot per station.
      Payload includes `overall`, `day_ahead`, and/or opted-in `day_of` tables matching the authenticated plan tier (same aggregation rules as GET `/api/v1/stations/{station_id}/oracle-scores` with default `days`, `rank_by=high`). If a mode-specific block is absent, treat as invalidation for that mode and re-fetch REST if needed.
      By default, legacy clients receive only `overall` and `day_ahead`; include `day_of` in `subscribe.oracle_score_modes` to receive day-of bootstrap and live updates.
      Delivered on both `/ws/api/1m` and `/ws/api/5m` without additional cadence throttling.
    messages:
      - $ref: "#/channels/api1m/messages/oracleScoresUpdated"

  receiveForecastVersions:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive available forecast versions for a city
    description: |
      Sent after city subscription so clients can compare local forecast versions
      and selectively refresh stale models.
      Delivered on both `/ws/api/1m` and `/ws/api/5m` channels.
    messages:
      - $ref: "#/channels/api1m/messages/forecastVersions"

  receiveNewHigh:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive new daily high temperature alert
    description: |
      Sent when the authoritative merged daily high for a subscribed city or station
      becomes more extreme. The winning value can come from observation ingest
      or committed station-report ingest.
      Delivered on both `/ws/api/1m` and `/ws/api/5m` channels.
      Subscribing to a city automatically includes alerts for all its stations.
    messages:
      - $ref: "#/channels/api1m/messages/newHigh"

  receiveNewLow:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive new daily low temperature alert
    description: |
      Sent when the authoritative merged daily low for a subscribed city or station
      becomes more extreme. The winning value can come from observation ingest
      or committed station-report ingest.
      Delivered on both `/ws/api/1m` and `/ws/api/5m` channels.
      Subscribing to a city automatically includes alerts for all its stations.
    messages:
      - $ref: "#/channels/api1m/messages/newLow"

  resume:
    action: send
    channel:
      $ref: "#/channels/api1m"
    summary: Replay hourly index events after a seq
    description: Replays logged events with `seq` greater than `after_seq`, then sends `resume_complete`; `resume_failed` means subscribe again for a snapshot.
    messages:
      - $ref: "#/channels/api1m/messages/hourlyIndexResume"

  receiveHourlyIndexSnapshot:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive an hourly index city snapshot
    description: Sent for each accepted `hourly_indexes` city after `subscribed` and before `snapshot_complete`.
    messages:
      - $ref: "#/channels/api1m/messages/hourlyIndexSnapshot"

  receiveHourlyIndexEvent:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive hourly index events
    description: Per-city events in collector `seq` order. Delivered on both `/ws/api/1m` and `/ws/api/5m`.
    messages:
      - $ref: "#/channels/api1m/messages/indexMinute"
      - $ref: "#/channels/api1m/messages/hourUpdate"
      - $ref: "#/channels/api1m/messages/hourSettled"
      - $ref: "#/channels/api1m/messages/hourReconciled"
      - $ref: "#/channels/api1m/messages/calibration"
      - $ref: "#/channels/api1m/messages/hourEvent"
      - $ref: "#/channels/api1m/messages/marketUpdate"
      - $ref: "#/channels/api1m/messages/forecastIndex"
      - $ref: "#/channels/api1m/messages/forecastBias"

  receiveHourlyIndexFileUpdated:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive a rewritten hourly index data file
    description: Unsequenced (no `seq`, not replayed by `resume`). Delivered on both `/ws/api/1m` and `/ws/api/5m` for `hourly_indexes` subscriptions.
    messages:
      - $ref: "#/channels/api1m/messages/indexFileUpdated"

  receiveResumeResult:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive the result of a resume
    messages:
      - $ref: "#/channels/api1m/messages/resumeComplete"
      - $ref: "#/channels/api1m/messages/resumeFailed"

  receiveError:
    action: receive
    channel:
      $ref: "#/channels/api1m"
    summary: Receive error message
    description: Sent when a client action fails. Delivered on unified API channels (`/ws/api/1m`, `/ws/api/5m`).
    messages:
      - $ref: "#/channels/api1m/messages/error"

components:
  messages:
    WeatherSubscribe:
      name: weather_subscribe
      summary: Subscribe to updates by city or station
      description: |
        Send this message on API channels (`/ws/api/1m`, `/ws/api/5m`)
        to start receiving events for specific cities and/or stations.
        City subscriptions retain their existing event families. Available station-scoped events
        are delivered automatically to direct station subscribers.

        **Station subscriptions** use ICAO codes (e.g. KORD, KFLL). Eligible stations are active
        cityless stations or active stations whose resolved parent city is active. They deliver
        events scoped to that station. Event availability depends on configured producers and data
        that already exists; subscribing does not enable observation, report, forecast, oracle,
        weather-event, high/low, or market
        producers. Cityless station events use the station ICAO as `station_id`, an empty `slug`,
        and no `city_sequence`. Price delivery additionally requires a real market-linked city and
        a matching market source.

        **Oracle score compatibility:** `oracle_score_modes` is optional. If omitted,
        the connection receives the legacy oracle score modes: `overall` and `day_ahead`.
        Include `day_of` to opt in to same-day forecast scoring payloads.
        The `subscribed` acknowledgement echoes the modes in effect as
        `accepted.oracle_score_modes`. The modes are per connection: a later `subscribe`
        that sends `oracle_score_modes` replaces them, one that omits it keeps them.

        **Startup ordering:** After processing, the server sends:
        1. `subscribed` acknowledgement
        2. Bootstrap payloads (cached observations, one per active station for each city, and forecast versions)
        3. `snapshot_complete` marker

      payload:
        type: object
        required:
          - type
        properties:
          type:
            type: string
            const: subscribe
          cities:
            type: array
            items:
              type: string
            description: City slugs to subscribe to (e.g. chi, nyc, lax)
            example:
              - chi
              - nyc
          stations:
            type: array
            items:
              type: string
            description: |
              ICAO station codes for active cityless stations or active stations whose resolved
              parent city is active (e.g. KORD, KFLL). Case-insensitive input is normalized to
              uppercase. Available on API channels (`/ws/api/1m`, `/ws/api/5m`).
              Observation-only (neighbour) stations count against the neighbour quota, every
              other station against the market quota; see Connection & Subscription Limits.
              The newly requested market stations are accepted or rejected together, and so are
              the newly requested neighbours. `cities` are checked before `stations` and
              `hourly_indexes` after, so one field can be accepted and a later one rejected.
            example:
              - KORD
              - KLGA
          map_stations:
            type: array
            items:
              type: string
            description: |
              `/ws/ui` only: ICAO codes of active stations a weather map shows, including
              observation-only neighbours. Each delivers that station's `observation` events and
              nothing else; a city subscription covering the same station still delivers each
              event once. Each newly subscribed station gets its latest observation as a
              snapshot before `snapshot_complete`. Free users get observations after the
              3-minute delay and never report-derived ones, and only of free stations (daily
              market settlement stations and members of hourly indexes without an hourly
              market); others are rejected with `paid plan required`. At most 100 per
              connection; the
              newly requested stations are accepted or rejected together with
              `map station limit exceeded (limit 100)`. They do not count against any other
              limit. Rejected on the API channels.
            example:
              - KMDW
              - KDPA
          hourly_indexes:
            type: array
            items:
              type: string
            description: |
              Kalshi index city IDs (`GET /api/v1/hourly-indexes`). One subscription slot per city;
              rejected with `unknown_index`, `inactive_index` or
              `market subscription limit exceeded (limit N)`.
            example:
              - miami
              - nyc
          oracle_score_modes:
            type: array
            description: |
              Optional oracle score capability list for this connection. Omit for the
              legacy default (`overall`, `day_ahead`). Include `day_of` to receive
              same-day forecast scoring in bootstrap snapshots and live updates.
              Unknown modes are ignored.
            items:
              type: string
              enum:
                - overall
                - day_ahead
                - day_of
            example:
              - overall
              - day_ahead
              - day_of

    WeatherUnsubscribe:
      name: weather_unsubscribe
      summary: Unsubscribe from updates by city or station
      payload:
        type: object
        required:
          - type
        properties:
          type:
            type: string
            const: unsubscribe
          cities:
            type: array
            items:
              type: string
            example:
              - nyc
          stations:
            type: array
            items:
              type: string
            description: ICAO station codes to unsubscribe from
            example:
              - KORD
          hourly_indexes:
            type: array
            items:
              type: string
            description: Index city IDs to unsubscribe from (releases their slots)
            example:
              - nyc
          map_stations:
            type: array
            items:
              type: string
            description: Weather map stations to unsubscribe from (`/ws/ui` only)
            example:
              - KDPA

    Subscribed:
      name: subscribed
      summary: Subscription acknowledgement
      description: |
        Sent after processing a subscribe request. Includes accepted and rejected subscriptions.
        Bootstrap payloads (cached observations, forecast versions) follow this message.
        `snapshot_complete` is sent after all bootstrap payloads.
      payload:
        type: object
        properties:
          type:
            type: string
            const: subscribed
          event_id:
            type: string
            format: uuid
            description: Unique identifier for this event payload
          sequence:
            type: integer
            description: Monotonic server sequence for deterministic processing
          emitted_at:
            type: string
            format: date-time
          accepted:
            type: object
            properties:
              cities:
                type: array
                items:
                  type: string
                description: Accepted city slugs
              city_details:
                type: array
                items:
                  type: object
                  properties:
                    slug:
                      type: string
                      description: City slug
                    stations:
                      type: array
                      items:
                        type: string
                      description: ICAO station codes in this city
                description: Detailed info per accepted city, listing which stations belong to each city
              stations:
                type: array
                items:
                  type: string
                description: Accepted ICAO station codes (uppercase)
              hourly_indexes:
                type: array
                items:
                  type: string
                description: Accepted index city IDs (each followed by an `hourly_index_snapshot`)
              map_stations:
                type: array
                items:
                  type: string
                description: Accepted weather map stations (`/ws/ui`), including ones already subscribed
              oracle_score_modes:
                type: array
                items:
                  type: string
                  enum:
                    - overall
                    - day_ahead
                    - day_of
                description: |
                  Oracle score modes active for this connection after this request, in request
                  order with duplicates removed. Unknown requested modes are dropped (compare
                  with the request to detect them) and the legacy alias `prior_day` is reported
                  as `day_ahead`. A connection that has not sent `oracle_score_modes`, or whose
                  latest list was empty or held no recognised mode, has the default: `overall`
                  and `day_ahead`. Present on every API channel acknowledgement, including one
                  whose subscriptions were all rejected.
                example:
                  - overall
                  - day_ahead
          rejected:
            type: array
            items:
              type: object
              properties:
                scope:
                  type: string
                  enum: [city, station, hourly_index, map_station]
                value:
                  type: string
                reason:
                  type: string

    SnapshotComplete:
      name: snapshot_complete
      summary: Initial bootstrap payload complete
      description: Marker that all immediate bootstrap payloads have been sent for the current subscribe action.
      payload:
        type: object
        properties:
          type:
            type: string
            const: snapshot_complete
          event_id:
            type: string
            format: uuid
            description: Unique identifier for this event payload
          sequence:
            type: integer
            description: Monotonic server sequence for deterministic processing
          emitted_at:
            type: string
            format: date-time
          scope:
            type: string
            enum: [subscribe]

    ObservationEvent:
      name: observation
      summary: Real-time weather observation from a station
      description: |
        Sent when an observation update is received for a subscribed city or station.
        A timestamp can emit multiple updates:
        - one update per change to the station's value for that timestamp, sent once,
          without waiting for the database write
        - WU-only update(s) where `temperature_f`/`temperature_c` may be null and only `wu_*` fields change

        On subscribe, each newly subscribed city gets one bootstrap `observation` per active
        station in the city that has a cached observation (stations without one are skipped),
        before `snapshot_complete`. Repeating a subscription for a city the connection already
        has does not resend its bootstrap. Free `/ws/ui` bootstrap observations respect the
        3-minute delay; for report-derived observations `temperature_f`/`temperature_c` are sent
        as `null` and the `temp_min_*`/`temp_max_*` and report provenance fields are omitted.

        Observation events omit `persistence_status`: the collector sends each update
        once, before and independently of its durable write, and does not send a
        committed confirmation. If that write ultimately fails, the observation may be
        missing from storage while the cache still shows it, until that minute is
        written again or leaves the rolling cache window (cache repair from the
        database only fills minutes the cache lacks).
      payload:
        type: object
        properties:
          type:
            type: string
            const: observation
          event_id:
            type: string
            format: uuid
            description: Unique identifier for this event payload
          sequence:
            type: integer
            description: Monotonic server sequence for deterministic processing
          city_sequence:
            type: integer
            description: Monotonic per-city sequence for city-scoped gap detection; omitted for cityless station events.
          emitted_at:
            type: string
            format: date-time
          event_key:
            type: string
            description: Stable producer key for this source event; use with `producer_sequence` for dedupe.
          source_timestamp:
            type: string
            format: date-time
            nullable: true
            description: Source-side timestamp for the weather value when available.
          wmo_emit_time:
            type: string
            format: date-time
            nullable: true
            description: WMO text-product emit time when available; distinct from source observation/issuance time.
          producer_received_at:
            type: string
            format: date-time
            nullable: true
            description: Time MinuteTemp's collector received the upstream event.
          live_published_at:
            type: string
            format: date-time
            nullable: true
            description: Time the collector confirmed live Redis publish.
          persistence_status:
            type: string
            enum: [uncommitted, committed, failed]
            description: Omitted on observation events (since v1.20); kept for compatibility.
          producer_sequence:
            type: integer
            format: int64
            description: Per-producer sequence used with `event_key` for ordering and dedupe.
          slug:
            type: string
            description: City slug, or the empty string for a cityless station event.
            example: chi
          station_id:
            type: string
            description: ICAO station code
            example: KMDW
          observed_at:
            type: string
            format: date-time
          lag_seconds:
            type: integer
            description: Seconds between observation and delivery
            example: 45
          preliminary:
            type: boolean
            description: |
              Legacy field. Observation updates omit it or set it to false (since v1.20).
          temperature_c:
            type: number
            nullable: true
            description: Temperature in Celsius
            example: 22.5
          temperature_f:
            type: number
            nullable: true
            description: Temperature in Fahrenheit
            example: 72.5
          wu_current_temp_f:
            type: number
            nullable: true
            description: Latest Weather Underground current temperature in Fahrenheit. A WU update carries the `wu_*` fields with null `temperature_*`; it is display only and never affects the daily high/low or the cached observations.
          wu_current_temp_c:
            type: number
            nullable: true
            description: Latest Weather Underground current temperature in Celsius.
          wu_daily_high_f:
            type: number
            nullable: true
            description: Weather Underground historical daily page high for the current station-local day in Fahrenheit.
          wu_daily_low_f:
            type: number
            nullable: true
            description: Weather Underground historical daily page low for the current station-local day in Fahrenheit.
          wu_daily_high_c:
            type: number
            nullable: true
            description: Weather Underground historical daily page high for the current station-local day in Celsius.
          wu_daily_low_c:
            type: number
            nullable: true
            description: Weather Underground historical daily page low for the current station-local day in Celsius.
          wu_observation_time:
            type: string
            format: date-time
            nullable: true
            description: Observation timestamp used for the current Weather Underground values.
          wu_fetched_at:
            type: string
            format: date-time
            nullable: true
            description: Timestamp when MinuteTemp fetched the latest Weather Underground observation.
          temperature_day_mode:
            type: string
            enum: [calendar_day, nws_climate_day]
            description: Day-bucketing mode used for station-observed temperature extremes and day labels.
          temperature_day_date:
            type: string
            format: date
            description: Station temperature-day label for this observation event.
          wu_day_mode:
            type: string
            enum: [calendar_day]
            description: Day-bucketing mode used for `wu_daily_*` values.
          wu_day_date:
            type: string
            format: date
            description: Station-local calendar date used for `wu_daily_*` values.
          is_from_report:
            type: boolean
            description: True when this observation value originated from an official report.
          report_type:
            type: string
            enum: [cli, dsm, metar_tgroup, metar_6hr]
          source_report_id:
            type: string
            description: Internal report ID linked to report-derived observations.
          dewpoint:
            type: number
            nullable: true
            description: Dew point in Celsius
          heat_index:
            type: number
            nullable: true
          wind_chill:
            type: number
            nullable: true
          relative_humidity:
            type: number
            nullable: true
          wind_speed:
            type: number
            nullable: true
          wind_direction:
            type: number
            nullable: true
          wind_gust:
            type: number
            nullable: true
          visibility:
            type: number
            nullable: true
            description: Visibility in meters. Omitted when unknown.
          barometric_pressure:
            type: number
            nullable: true
            description: Pressure in pascals. Omitted when unknown.
          sea_level_pressure:
            type: number
            nullable: true
            description: Pressure in pascals. Omitted when unknown.
          precipitation_1h:
            type: number
            nullable: true
            description: Precipitation over the last hour in millimeters. Omitted when unknown; a measured 0 is sent as 0.
          precipitation_3h:
            type: number
            nullable: true
            description: Precipitation over the last 3 hours in millimeters. Omitted when unknown; a measured 0 is sent as 0.
          precipitation_6h:
            type: number
            nullable: true
            description: Precipitation over the last 6 hours in millimeters. Omitted when unknown; a measured 0 is sent as 0.
          text_description:
            type: string
            example: Partly Cloudy
          weather_fields_observed_at:
            type: string
            format: date-time
            nullable: true
            description: >
              Set when the non-temperature fields (dewpoint, humidity, pressure, wind,
              precipitation) were carried forward from an earlier measurement
              rather than measured at `observation_time`. Holds the observation time of
              the oldest carried value. Absent when every field was measured at
              `observation_time`. Stations with 1-minute temperature and slower weather
              fields (5-minute HFMETAR, or a 30- to 60-minute METAR) carry the latest
              values until the next update, for at most 75 minutes.
          temp_min_f:
            type: number
            nullable: true
            description: >
              Lower bound of the temperature precision range (Fahrenheit).
              Derived from integer Celsius measurement precision (+/-0.5C).
          temp_max_f:
            type: number
            nullable: true
            description: >
              Upper bound of the temperature precision range (Fahrenheit).
              Derived from integer Celsius measurement precision (+/-0.5C).
          temp_min_c:
            type: number
            nullable: true
            description: >
              Lower bound of the temperature precision range (Celsius).
              Derived from integer Celsius measurement precision (+/-0.5C).
          temp_max_c:
            type: number
            nullable: true
            description: >
              Upper bound of the temperature precision range (Celsius).
              Derived from integer Celsius measurement precision (+/-0.5C).

    StationReportEvent:
      name: station_report
      summary: Official station report publication
      description: |
        Sent when a new official CLI/DSM/METAR report is ingested for a subscribed city or station.
        Includes issuance metadata, occurrence timestamps, and source attribution.
        Committed station reports can recompute the merged daily high/low used by
        REST `daily_*` fields and can emit `new_high`/`new_low` when they make the
        merged daily extreme more extreme.
      payload:
        type: object
        properties:
          type:
            type: string
            const: station_report
          event_id:
            type: string
            format: uuid
            description: Unique identifier for this event payload
          sequence:
            type: integer
            description: Monotonic server sequence for deterministic processing
          city_sequence:
            type: integer
            description: Monotonic per-city sequence for city-scoped gap detection.
          emitted_at:
            type: string
            format: date-time
          event_key:
            type: string
            description: Stable producer key for this source event; use with `producer_sequence` for dedupe.
          source_timestamp:
            type: string
            format: date-time
            nullable: true
            description: Source-side timestamp for the report when available.
          wmo_emit_time:
            type: string
            format: date-time
            nullable: true
            description: WMO text-product emit time when available; distinct from report observation/issuance time.
          producer_received_at:
            type: string
            format: date-time
            nullable: true
            description: Time MinuteTemp's collector received the upstream report.
          live_published_at:
            type: string
            format: date-time
            nullable: true
            description: Time the collector confirmed live Redis publish.
          persistence_status:
            type: string
            enum: [uncommitted, committed, failed]
            description: Durable-write state for live-first reports. Omitted on legacy events.
          producer_sequence:
            type: integer
            format: int64
            description: Per-producer sequence used with `event_key` for ordering and dedupe.
          slug:
            type: string
            description: City slug
            example: chi
          station_id:
            type: string
            description: ICAO station code
            example: KMDW
          report_id:
            type: string
            description: Internal report row identifier.
          report_revision:
            type: integer
            description: Monotonic revision for report corrections.
          report_updated_at:
            type: string
            format: date-time
            description: Last update timestamp for this report row.
          report_type:
            type: string
            enum: [cli, dsm, metar_tgroup, metar_6hr]
          report_date:
            type: string
            format: date
            description: Target local calendar date represented by this report.
          issuance_time:
            type: string
            format: date-time
          fetched_at:
            type: string
            format: date-time
          source_url:
            type: string
          max_temp_f:
            type: number
            nullable: true
          max_temp_c:
            type: number
            nullable: true
          max_temp_time_utc:
            type: string
            format: date-time
            nullable: true
          min_temp_f:
            type: number
            nullable: true
          min_temp_c:
            type: number
            nullable: true
          min_temp_time_utc:
            type: string
            format: date-time
            nullable: true
          temp_f:
            type: number
            nullable: true
          temp_c:
            type: number
            nullable: true
          provider:
            type: string
          official:
            $ref: '#/components/schemas/OfficialDayExtremes'

    WeatherEventEvent:
      name: weather_event
      summary: Weather event lifecycle update
      description: |
        Sent when a notable station weather event is detected, refreshed, or marked ended
        for a subscribed city or station.
      payload:
        type: object
        properties:
          type:
            type: string
            const: weather_event
          event_id:
            type: string
            format: uuid
            description: Unique identifier for this event payload
          sequence:
            type: integer
            description: Monotonic server sequence for deterministic processing
          city_sequence:
            type: integer
            description: Monotonic per-city sequence for city-scoped gap detection.
          emitted_at:
            type: string
            format: date-time
          slug:
            type: string
            description: City slug
            example: chi
          station_id:
            type: string
            description: ICAO station code
            example: KMDW
          id:
            type: string
            format: uuid
            description: Stable event episode identifier.
          event_type:
            type: string
            example: thunderstorm
          tier:
            type: string
            enum: [tier1, tier2, tier3]
          state:
            type: string
            enum: [active, ended]
          name:
            type: string
          badge:
            type: string
          detail:
            type: string
          summary:
            type: string
          started_at:
            type: string
            format: date-time
          last_confirmed_at:
            type: string
            format: date-time
          ended_at:
            type: string
            format: date-time
            nullable: true
          source:
            type: object
            properties:
              metar_type:
                type: string
              flight_category:
                type: string
              wx_string:
                type: string
              wx_token:
                type: string
              wind_speed_kt:
                type: number
                nullable: true
              wind_gust_kt:
                type: number
                nullable: true
              peak_wind_kt:
                type: number
                nullable: true
              peak_wind_direction:
                type: integer
                nullable: true
              visibility_mi:
                type: number
                nullable: true
              cb_location:
                type: string

    PriceUpdateEvent:
      name: price_update
      summary: Prediction market change
      description: |
        Sent when a subscribed city's or station's markets change on `/ws/api/1m` and
        `/ws/api/5m`. Since v1.29 the API channels carry no market prices: the markets keep
        their identifiers, strikes and statuses, and every price field is omitted. The event is
        still sent for every change, so `sequence` and `city_sequence` stay gap-free.
        `kalshi`, `polymarket` and `forecastex` publish websocket price updates. A
        `forecastex` station subscription needs a station that enables Robinhood or IBKR,
        which broker the ForecastEx contracts. ForecastEx markets carry `market_date`,
        `strike`, `comparator`, `expires_at`, `last_trade_at`, `status` and
        `computed_outcome`, and `price_display` (false). A ForecastEx `price_update` is sent
        when the contracts change (and, while MinuteTemp displays ForecastEx prices on its own
        site, when only their prices change: the update then carries nothing new; a city's
        first publish after a server restart sends nothing), and a station subscription receives it
        only for the station the contracts settle on. A city whose contracts are gone gets
        `markets: []`.
        A station subscription receives market updates only when the station belongs to a
        real market-linked city and that station enables the matching market source.
        A cityless station subscription does not receive `price_update` events.
      payload:
        type: object
        properties:
          type:
            type: string
            const: price_update
          event_id:
            type: string
            format: uuid
            description: Unique identifier for this event payload
          sequence:
            type: integer
            description: Monotonic server sequence for deterministic processing
          city_sequence:
            type: integer
            description: Monotonic per-city sequence for city-scoped gap detection.
          emitted_at:
            type: string
            format: date-time
          source:
            type: string
            enum:
              - kalshi
              - polymarket
              - forecastex
          price_display:
            type: boolean
            description: ForecastEx only. Whether the markets carry prices; always false on the API channels.
          slug:
            type: string
            example: chi
          city_id:
            type: string
          timestamp:
            type: string
            format: date-time
          markets:
            type: array
            items:
              type: object
              properties:
                market_id:
                  type: string
                ticker:
                  type: string
                yes_price:
                  type: number
                  deprecated: true
                  description: "Deprecated: never sent on the API channels (since v1.29)."
                no_price:
                  type: number
                  deprecated: true
                  description: "Deprecated: never sent on the API channels (since v1.29)."
                event_ticker:
                  type: string
                event_date:
                  type: string
                  format: date
                strike_type:
                  type: string
                floor_strike:
                  type: number
                  nullable: true
                cap_strike:
                  type: number
                  nullable: true
                snapshot_time:
                  type: string
                  format: date-time
                yes_bid:
                  type: number
                  nullable: true
                  deprecated: true
                  description: "Deprecated: never sent on the API channels (since v1.29)."
                yes_ask:
                  type: number
                  nullable: true
                  deprecated: true
                  description: "Deprecated: never sent on the API channels (since v1.29)."
                no_bid:
                  type: number
                  nullable: true
                  deprecated: true
                  description: "Deprecated: never sent on the API channels (since v1.29)."
                no_ask:
                  type: number
                  nullable: true
                  deprecated: true
                  description: "Deprecated: never sent on the API channels (since v1.29)."
                volume:
                  type: number
                  deprecated: true
                  description: "Deprecated: never sent on the API channels (since v1.29)."
                market_type:
                  type: string
                  description: ForecastEx only. `high` or `low`.
                market_date:
                  type: string
                  format: date
                  description: ForecastEx only. The contract's local day (in place of `event_date`).
                strike:
                  type: number
                  description: ForecastEx only.
                comparator:
                  type: string
                  enum: [gt, lt]
                  description: ForecastEx only. YES above (`gt`) or below (`lt`) the strike.
                range_unit:
                  type: string
                  description: ForecastEx only.
                expires_at:
                  type: string
                  format: date-time
                  description: ForecastEx only.
                last_trade_at:
                  type: string
                  format: date-time
                  description: ForecastEx only.
                status:
                  type: string
                  enum: [open, closed, settled]
                  description: ForecastEx only.
                computed_outcome:
                  type: string
                  description: ForecastEx only. MinuteTemp's own settlement, once computed.
                computed_value:
                  type: number
                  description: ForecastEx only. The observed value MinuteTemp settled on.
                open_interest:
                  deprecated: true
                  type: integer
                  description: "ForecastEx only. Deprecated: never sent on the API channels (since v1.29)."
                vwap:
                  deprecated: true
                  type: number
                  description: "ForecastEx only. Deprecated: never sent on the API channels (since v1.29)."
                settlement_price:
                  deprecated: true
                  type: number
                  description: "ForecastEx only. Deprecated: never sent on the API channels (since v1.29)."
                audit_status:
                  deprecated: true
                  type: string
                  description: |
                    ForecastEx only. Deprecated: never sent on the API channels (since v1.29). MinuteTemp's audit of its
                    computed outcome: `pending` (not settled yet), `match` or `mismatch` (against the
                    exchange's settlement), `no_data`, or `computed_only` (an hour the exchange's
                    reports never settle, final on MinuteTemp's computed value; not an official result).

    ForecastUpdatedEvent:
      name: forecast_updated
      summary: A new forecast run is available; fetch its file from `path`
      description: |
        Sent when a new forecast run is available for a subscribed city or station. This event is
        the trigger to fetch the run's forecast data file: request the data host's `base_url` +
        `path` + `?` + the `query` of the grant for the path's date, both from
        `GET /api/v1/data/access`. The token is never part of this event. Without `path`, no file
        is advertised for this run on this stream (see `path`). Missed events (after a reconnect) are recovered from
        `GET /api/v1/stations/{station_id}/forecast/runs` and its `object_path`. The full flow,
        with examples, is "Getting forecast data" in the API reference at /docs/api.
      payload:
        type: object
        properties:
          type:
            type: string
            const: forecast_updated
          event_id:
            type: string
            format: uuid
            description: Unique identifier for this event payload
          sequence:
            type: integer
            description: Monotonic server sequence for deterministic processing
          emitted_at:
            type: string
            format: date-time
          slug:
            type: string
          station_id:
            type: string
          model_id:
            type: string
            example: ncep_hrrr_conus
          version:
            type: string
            format: date-time
            description: Timestamp of the forecast run
          run_id:
            type: string
            format: uuid
            description: ID of the forecast run. Present only when the run has a forecast data file.
          path:
            type: string
            description: |
              Path of the run's forecast data file on the data host, present only when there is one
              for this stream. On `/ws/api/*` and the paid `/ws/ui` stream it is the private file
              (`/private/v1/forecasts/...`), read with a token from the data access endpoint
              (`GET /api/v1/data/access`; the token is never part of this event). On the free
              `/ws/ui` stream it is the public copy (`/public/v1/forecasts/...`), which needs no
              token, and is absent when the run has no public copy. A path is sent once, with the
              event of a newly stored run; a run whose file is written later gets no further event.
            example: /private/v1/forecasts/2026/10/12/KMDW/ncep_hrrr_conus/0199d2a0-0000-7000-8000-000000000001.json

    OracleScoresUpdatedEvent:
      name: oracle_scores_updated
      summary: Oracle score tables for the connection tier (push + bootstrap)
      description: |
        Sent when oracle scores change for a subscribed city or station, and on subscribe as a per-station bootstrap.
        Includes optional `overall` / `day_ahead` / opted-in `day_of` objects matching the `data` object from GET `/api/v1/stations/{station_id}/oracle-scores` for this connection's plan tier (default `days`, `rank_by=high`).
        Legacy clients that omit `oracle_score_modes` on subscribe receive only `overall` and `day_ahead`; `day_of` is delivered only when requested.
        When a mode block is omitted, re-fetch that mode via REST if your client still needs it.
      payload:
        type: object
        properties:
          type:
            type: string
            const: oracle_scores_updated
          event_id:
            type: string
            format: uuid
            description: Unique identifier for this event payload
          sequence:
            type: integer
            description: Monotonic server sequence for deterministic processing
          emitted_at:
            type: string
            format: date-time
          slug:
            type: string
          station_id:
            type: string
          modes:
            type: array
            description: Oracle score modes included in this notification after applying this connection's `oracle_score_modes` opt-in filter.
            items:
              type: string
              enum:
                - overall
                - day_ahead
                - day_of
          updated_at:
            type: string
            format: date-time
            description: Timestamp when the oracle refresh was emitted (omitted on some bootstrap snapshots)
          overall:
            $ref: "#/components/schemas/OracleScoreTablePayload"
          day_ahead:
            $ref: "#/components/schemas/OracleScoreTablePayload"
          day_of:
            $ref: "#/components/schemas/OracleScoreTablePayload"

    ForecastVersionsEvent:
      name: forecast_versions
      summary: Forecast model versions currently available for a city
      description: |
        Sent after city subscription. Provides a model->version map so clients
        can selectively refresh stale forecast models.
      payload:
        type: object
        properties:
          type:
            type: string
            const: forecast_versions
          event_id:
            type: string
            format: uuid
            description: Unique identifier for this event payload
          sequence:
            type: integer
            description: Monotonic server sequence for deterministic processing
          emitted_at:
            type: string
            format: date-time
          slug:
            type: string
            description: City slug
          station_id:
            type: string
            description: Station code
          versions:
            type: object
            description: Map of model ID to latest version timestamp
            additionalProperties:
              type: string
              format: date-time

    NewHighEvent:
      name: new_high
      summary: New daily high temperature detected
      description: |
        Sent when the authoritative merged daily high becomes more extreme from
        observation ingest or committed station-report ingest. Observation-sourced
        alerts are emitted from the observation's live path and omit
        `persistence_status`; report-sourced alerts are committed.
        The first temperature event for a day can fire both
        `new_high` and `new_low` with `prev_value_f` omitted. Daily boundary is defined by
        `temperature_day_mode` and `temperature_day_date`.

        **Ordering note:** `new_high` and `new_low` are emitted for more-extreme
        transitions only. Later report revisions that make the day less extreme
        repair served REST daily highs/lows but do not emit correction events.
        Clients that need the exact current daily high/low after any report event
        should reconcile with REST latest-observation `daily_*` fields.
      payload:
        type: object
        properties:
          type:
            type: string
            const: new_high
          event_id:
            type: string
            format: uuid
            description: Unique identifier for this event payload
          sequence:
            type: integer
            description: Monotonic server sequence for deterministic processing
          city_sequence:
            type: integer
            description: Monotonic per-city sequence for city-scoped gap detection.
          emitted_at:
            type: string
            format: date-time
          event_key:
            type: string
            description: Stable producer key for the source observation/report when available.
          source_timestamp:
            type: string
            format: date-time
            nullable: true
            description: Source-side timestamp for the winning value when available.
          wmo_emit_time:
            type: string
            format: date-time
            nullable: true
            description: WMO text-product emit time when the source is an LDM text report.
          producer_received_at:
            type: string
            format: date-time
            nullable: true
            description: Time MinuteTemp's collector received the source event when available.
          live_published_at:
            type: string
            format: date-time
            nullable: true
            description: Time the collector confirmed live Redis publish when available.
          persistence_status:
            type: string
            enum: [uncommitted, committed, failed]
            description: Omitted on observation-sourced alerts (since v1.20). Report-sourced alerts are committed or omit this legacy field.
          producer_sequence:
            type: integer
            format: int64
            description: Per-producer sequence used with `event_key` for ordering and dedupe.
          slug:
            type: string
            description: City slug
            example: chi
          station_id:
            type: string
            description: ICAO station code that reported the new high
            example: KMDW
          value_f:
            type: number
            description: New daily high temperature in Fahrenheit
            example: 78.5
          value_c:
            type: number
            description: New daily high temperature in Celsius
            example: 25.8
          prev_value_f:
            type: number
            nullable: true
            description: Previous merged daily high before this event (null when there was no previous cached high).
            example: 76.0
          observed_at:
            type: string
            format: date-time
            description: Timestamp of the winning observation when known, otherwise report issuance time fallback
          temperature_day_mode:
            type: string
            enum: [calendar_day, nws_climate_day]
            description: Day-bucketing mode for this daily high event.
          temperature_day_date:
            type: string
            format: date
            description: Temperature-day label for this daily high event.
          is_from_report:
            type: boolean
            description: True when the authoritative extreme came from a station report.
          report_type:
            type: string
            enum: [cli, dsm, metar_tgroup, metar_6hr]
          source_report_id:
            type: string
            description: Internal station report ID when this extreme came from report ingest.

    NewLowEvent:
      name: new_low
      summary: New daily low temperature detected
      description: |
        Sent when the authoritative merged daily low becomes more extreme from
        observation ingest or committed station-report ingest. Observation-sourced
        alerts are emitted from the observation's live path and omit
        `persistence_status`; report-sourced alerts are committed.
        The first temperature event for a day can fire both
        `new_high` and `new_low` with `prev_value_f` omitted. Daily boundary is defined by
        `temperature_day_mode` and `temperature_day_date`.

        **Ordering note:** `new_high` and `new_low` are emitted for more-extreme
        transitions only. Later report revisions that make the day less extreme
        repair served REST daily highs/lows but do not emit correction events.
        Clients that need the exact current daily high/low after any report event
        should reconcile with REST latest-observation `daily_*` fields.
      payload:
        type: object
        properties:
          type:
            type: string
            const: new_low
          event_id:
            type: string
            format: uuid
            description: Unique identifier for this event payload
          sequence:
            type: integer
            description: Monotonic server sequence for deterministic processing
          city_sequence:
            type: integer
            description: Monotonic per-city sequence for city-scoped gap detection.
          emitted_at:
            type: string
            format: date-time
          event_key:
            type: string
            description: Stable producer key for the source observation/report when available.
          source_timestamp:
            type: string
            format: date-time
            nullable: true
            description: Source-side timestamp for the winning value when available.
          wmo_emit_time:
            type: string
            format: date-time
            nullable: true
            description: WMO text-product emit time when the source is an LDM text report.
          producer_received_at:
            type: string
            format: date-time
            nullable: true
            description: Time MinuteTemp's collector received the source event when available.
          live_published_at:
            type: string
            format: date-time
            nullable: true
            description: Time the collector confirmed live Redis publish when available.
          persistence_status:
            type: string
            enum: [uncommitted, committed, failed]
            description: Omitted on observation-sourced alerts (since v1.20). Report-sourced alerts are committed or omit this legacy field.
          producer_sequence:
            type: integer
            format: int64
            description: Per-producer sequence used with `event_key` for ordering and dedupe.
          slug:
            type: string
            description: City slug
            example: chi
          station_id:
            type: string
            description: ICAO station code that reported the new low
            example: KMDW
          value_f:
            type: number
            description: New daily low temperature in Fahrenheit
            example: 32.0
          value_c:
            type: number
            description: New daily low temperature in Celsius
            example: 0.0
          prev_value_f:
            type: number
            nullable: true
            description: Previous merged daily low before this event (null when there was no previous cached low).
            example: 34.5
          observed_at:
            type: string
            format: date-time
            description: Timestamp of the winning observation when known, otherwise report issuance time fallback
          temperature_day_mode:
            type: string
            enum: [calendar_day, nws_climate_day]
            description: Day-bucketing mode for this daily low event.
          temperature_day_date:
            type: string
            format: date
            description: Temperature-day label for this daily low event.
          is_from_report:
            type: boolean
            description: True when the authoritative extreme came from a station report.
          report_type:
            type: string
            enum: [cli, dsm, metar_tgroup, metar_6hr]
          source_report_id:
            type: string
            description: Internal station report ID when this extreme came from report ingest.

    HourlyIndexResume:
      name: resume
      summary: Replay hourly index events after a seq
      payload:
        type: object
        required: [type, hourly_indexes]
        properties:
          type:
            type: string
            const: resume
          hourly_indexes:
            type: array
            items:
              type: object
              required: [city, after_seq]
              properties:
                city:
                  type: string
                after_seq:
                  type: integer
                  description: Last seq the client applied for the city.
            example:
              - city: miami
                after_seq: 48213

    HourlyIndexSnapshotEvent:
      name: hourly_index_snapshot
      summary: Hourly index city snapshot
      description: |
        The `GET /api/v1/hourly-indexes/{city}` body plus `minutes` (the last 75 minutes, with member
        readings). `seq` is the last event folded in; apply only events with a higher seq. `file`
        names the city's current hourly index data file for the connection (see
        `index_file_updated`).
      payload:
        allOf:
          - $ref: "#/components/schemas/HourlyIndexEnvelope"
          - $ref: "#/components/schemas/HourlyIndexCity"
          - type: object
            properties:
              type:
                type: string
                const: hourly_index_snapshot
              minutes:
                type: array
                items:
                  $ref: "#/components/schemas/HourlyIndexMinute"
              file:
                $ref: "#/components/schemas/HourlyIndexFilePointer"

    HourlyIndexFileUpdatedEvent:
      name: index_file_updated
      summary: The city's current hourly index data file was rewritten
      description: |
        Sent to the city's `hourly_indexes` subscribers when its current hourly index data file is
        rewritten (the private file at most every 30 seconds, the public copy at most every 15
        minutes). It has no `seq` and
        no `event_id`: it is not logged, not replayed by `resume`, and does not take part in the
        city's seq order, so do not count it in gap detection. Fetch `path` from the data host
        (`base_url` + `path` + `?` + the `hourly_indexes` query of `GET /api/v1/data/access`) and
        keep the file only when its `generation` is above the one you hold. API channels and paid
        `/ws/ui` connections get the private file; free `/ws/ui` connections get the public copy
        of index-only cities, which needs no token. See "Hourly index data files" in the
        [API reference](/docs/api).
      payload:
        allOf:
          - type: object
            required: [type, city]
            properties:
              type:
                type: string
                const: index_file_updated
              city:
                type: string
                example: chicago
          - $ref: "#/components/schemas/HourlyIndexFilePointer"
      examples:
        - payload:
            type: index_file_updated
            city: chicago
            generation: 88123
            path: /private/v1/hourly-indexes/chicago.json
            as_of: "2026-10-14T17:15:00Z"

    IndexMinuteEvent:
      name: index_minute
      summary: A city-minute was published or revised
      description: |
        Sent when our provisional value changes, when Kalshi's official value arrives, and when a
        minute is declared missing. Keep the highest `revision` per minute.
      payload:
        allOf:
          - $ref: "#/components/schemas/HourlyIndexEnvelope"
          - type: object
            properties:
              type:
                type: string
                const: index_minute
          - $ref: "#/components/schemas/HourlyIndexMinute"

    HourUpdateEvent:
      name: hour_update
      summary: The open or pending hour changed
      payload:
        allOf:
          - $ref: "#/components/schemas/HourlyIndexEnvelope"
          - type: object
            properties:
              type:
                type: string
                const: hour_update
          - $ref: "#/components/schemas/HourlyIndexHour"

    HourSettledEvent:
      name: hour_settled
      summary: Our settlement of an hour was determined
      payload:
        allOf:
          - $ref: "#/components/schemas/HourlyIndexEnvelope"
          - type: object
            properties:
              type:
                type: string
                const: hour_settled
          - $ref: "#/components/schemas/HourlyIndexSettlement"

    HourReconciledEvent:
      name: hour_reconciled
      summary: Kalshi finalized the hour; carries its value and match
      payload:
        allOf:
          - $ref: "#/components/schemas/HourlyIndexEnvelope"
          - type: object
            properties:
              type:
                type: string
                const: hour_reconciled
          - $ref: "#/components/schemas/HourlyIndexSettlement"

    CalibrationEvent:
      name: calibration
      summary: A new calibration record (config version) was published
      payload:
        allOf:
          - $ref: "#/components/schemas/HourlyIndexEnvelope"
          - type: object
            properties:
              type:
                type: string
                const: calibration
          - $ref: "#/components/schemas/HourlyIndexCalibration"

    HourEventEvent:
      name: hour_event
      summary: An hourly Kalshi event was listed, opened or closed
      payload:
        allOf:
          - $ref: "#/components/schemas/HourlyIndexEnvelope"
          - type: object
            properties:
              type:
                type: string
                const: hour_event
          - $ref: "#/components/schemas/HourlyIndexEvent"

    MarketUpdateEvent:
      name: market_update
      summary: Hourly market quotes changed (at most one per second per city)
      payload:
        allOf:
          - $ref: "#/components/schemas/HourlyIndexEnvelope"
          - type: object
            properties:
              type:
                type: string
                const: market_update
              markets:
                type: array
                items:
                  $ref: "#/components/schemas/HourlyIndexMarketQuote"

    ForecastIndexEvent:
      name: forecast_index
      summary: A new 15-minute index forecast for the city
      description: |
        Published when a member's HRRR 15-minute run or a calibration changes (about hourly).
        Replaces the snapshot's `forecast`; every per-step array is aligned to `steps`.

        Where RRFS is enabled, the same event is also published when a member's RRFS run changes:
        `forecasts` then carries the new RRFS forecast and the top-level fields repeat the HRRR
        forecast unchanged (before the first HRRR forecast they are an empty HRRR forecast: every
        array `[]`). Replace your cached forecast of each model in `forecasts`; a model not listed
        is unchanged. An entry without steps (every array `[]`) means that model has no forecast
        any more (it expired or lost its member coverage): drop your cached one.
      payload:
        allOf:
          - $ref: "#/components/schemas/HourlyIndexEnvelope"
          - type: object
            properties:
              type:
                type: string
                const: forecast_index
              forecasts:
                type: object
                description: |
                  Other models' forecasts this event brings, by model ID (today only
                  `ncep_rrfs_conus_15min`). Absent on an HRRR refresh. An entry without steps
                  removes that model's forecast.
                additionalProperties:
                  $ref: "#/components/schemas/HourlyIndexForecast"
          - $ref: "#/components/schemas/HourlyIndexForecast"

    ForecastBiasEvent:
      name: forecast_bias
      summary: The city forecast's bias changed
      description: |
        Published when the bias of the city's forecast changes: a new or revised valued index
        minute, or a calibration recompute (deduplicated: an unchanged bias sends nothing). It
        carries a normal per-city `seq` and is replayed by `resume` like every other event. It
        does not resend the curve: keep the last
        `forecast_index` (or snapshot `forecast`) and apply `adjusted_f[i] = value_f[i] + bias_f`,
        and each settle's `adjusted_value_f = value_f + bias_f`. A new `forecast_index` carries its
        own bias fields.

        The top-level fields are the HRRR forecast's bias. `biases` carries the bias of every
        other model with a forecast (RRFS), each measured against the same index minute with that
        model's own forecast; the event is published when any of them changed, so the top-level
        fields may repeat the HRRR bias you already have.
      payload:
        allOf:
          - $ref: "#/components/schemas/HourlyIndexEnvelope"
          - type: object
            properties:
              type:
                type: string
                const: forecast_bias
              bias_f:
                type: [number, "null"]
                description: Newest valued index minute minus the forecast at that minute; null clears the bias.
              bias_as_of_minute: {type: [string, "null"], format: date-time}
              member_bias:
                type: [object, "null"]
                description: Per member, its reading at bias_as_of_minute minus its HRRR temperature there (°F).
                additionalProperties: {type: [number, "null"]}
              config_version: {type: string}
              biases:
                type: object
                description: |
                  The other models' biases, by model ID (today only `ncep_rrfs_conus_15min`);
                  absent while no other model has a forecast for the city.
                additionalProperties:
                  type: object
                  properties:
                    bias_f: {type: [number, "null"]}
                    bias_as_of_minute: {type: [string, "null"], format: date-time}
                    member_bias:
                      type: [object, "null"]
                      description: Per member, its reading at bias_as_of_minute minus that model's temperature there (°F).
                      additionalProperties: {type: [number, "null"]}
                    config_version: {type: string}
      examples:
        - payload:
            type: forecast_bias
            event_id: 3c9d8a52-4f1f-4a4e-9a55-2f0d7b7c1e11
            emitted_at: "2026-09-30T13:05:46Z"
            city: miami
            seq: 7412
            bias_f: 0.62
            bias_as_of_minute: "2026-09-30T13:04:00Z"
            member_bias: {KMIA: 0.9, KOPF: 0.4, KFLL: 0.55, KFXE: 0.71, KPMP: null}
            config_version: miami-temperature-v1.0-cal-20260928

    ResumeCompleteEvent:
      name: resume_complete
      summary: Resume replay finished
      payload:
        type: object
        properties:
          type:
            type: string
            const: resume_complete
          city:
            type: string
          seq:
            type: integer
            description: Last replayed seq (the requested after_seq when nothing was missed).

    ResumeFailedEvent:
      name: resume_failed
      summary: Resume is not possible; subscribe again for a snapshot
      payload:
        type: object
        properties:
          type:
            type: string
            const: resume_failed
          city:
            type: string
          reason:
            type: string
            enum: [seq_expired, seq_gap, not_subscribed, invalid_seq, unavailable]
            description: "`seq_expired`: after_seq is older than the replay log. `seq_gap`: the log is missing a seq after after_seq (events up to it are replayed first). Either way, resubscribe for a fresh snapshot."

    ErrorEvent:
      name: error
      summary: Error message from server
      description: Sent when a client action fails (e.g. subscription limit exceeded, invalid message).
      payload:
        type: object
        properties:
          type:
            type: string
            const: error
          event_id:
            type: string
            format: uuid
            description: Unique identifier for this event payload
          sequence:
            type: integer
            description: Monotonic server sequence for deterministic processing
          emitted_at:
            type: string
            format: date-time
          message:
            type: string
            example: market subscription limit exceeded (limit 6)

  schemas:
    OfficialExtreme:
      type: object
      nullable: true
      description: One official daily extreme and the report it came from.
      properties:
        temp_f:
          type: number
        temp_c:
          type: number
        report_type:
          type: string
          enum: [cli, dsm]
        issuance_time:
          type: string
          format: date-time
        phase:
          type: string
          enum: [final, in_day]
          description: CLI only. `final` is the full-day CLI (issued after local standard midnight), `in_day` a preliminary one.
        correction:
          type: boolean
          description: The CLI is a marked correction (BBB CCx/AAx).
        fallback:
          type: string
          enum: [other_final, in_day]
          description: >
            `other_final`: the full-day CLI the rule picks first is MM for this side, another
            full-day CLI's value is used. `in_day`: no full-day CLI has a value, the latest
            in-day CLI's is used.
        unmarked_revision:
          type: boolean
          description: NWS re-sent the full-day CLI with a changed value and no correction marker; `settlement_preview` keeps the first value.

    OfficialDayExtremes:
      type: object
      description: A report date's official high and low.
      properties:
        report_date:
          type: string
          format: date
        high:
          $ref: '#/components/schemas/OfficialExtreme'
        low:
          $ref: '#/components/schemas/OfficialExtreme'
        settlement_preview:
          type: object
          properties:
            high:
              $ref: '#/components/schemas/OfficialExtreme'
            low:
              $ref: '#/components/schemas/OfficialExtreme'
        as_of:
          type: string
          format: date-time
          description: When the newest report this day was resolved from was stored, to the microsecond. Of two answers for a date, keep the later; on equal `as_of`, the one with the larger `report_count`; on a full tie, the one you hold.
        report_count:
          type: integer
          description: How many CLI/DSM reports this day was resolved from.

    HourlyIndexEnvelope:
      type: object
      required: [type, city, seq]
      properties:
        type:
          type: string
        event_id:
          type: string
          format: uuid
        emitted_at:
          type: string
          format: date-time
        city:
          type: string
          example: miami
        seq:
          type: integer
          description: Per-city event sequence assigned by the collector; identical on every API instance.

    HourlyIndexFilePointer:
      type: object
      description: |
        The city's current hourly index data file for the connection: the private file
        (`/private/v1/hourly-indexes/{city}.json`) on API channels and paid `/ws/ui`, the public
        copy (`/public/v1/hourly-indexes/{city}.json`, index-only cities) on free `/ws/ui`.
      required: [generation, path, as_of]
      properties:
        generation:
          type: integer
          description: Orders the files of a city; a higher generation is newer.
          example: 88123
        path:
          type: string
          description: The file's path on the data host (`base_url` of `GET /api/v1/data/access`).
          example: /private/v1/hourly-indexes/chicago.json
        as_of:
          type: string
          format: date-time
          description: Every change committed before this time is in the file.

    HourlyIndexStationReading:
      type: object
      properties:
        station_id: {type: string}
        temp_f: {type: [number, "null"]}
        temp_c: {type: [number, "null"]}
        code: {type: string, description: "ok, missing, late, range, rate_spatial, extreme or pending"}
        source: {type: [string, "null"], description: "hf_asos or metar"}
        obs_time: {type: string, format: date-time}
        received_at: {type: string, format: date-time}
        primary_code: {type: string}
        offset_c: {type: [number, "null"]}
        weight: {type: number}
        contributes: {type: boolean}
        pull_f:
          type: [number, "null"]
          description: >-
            Leave-one-out pull on the index at this minute, °F (the index minus the index without this
            station); null when it doesn't contribute or is the only contributor.
        change_5m_f:
          type: [number, "null"]
          description: >-
            This reading minus the reading 5 minutes earlier (or the nearest earlier minute with a
            reading within 10 minutes of that), °F.
        hour_high_f: {type: [number, "null"], description: "Highest reading in the settle hour through this minute, °F"}
        hour_low_f: {type: [number, "null"], description: "Lowest reading in the settle hour through this minute, °F"}

    HourlyIndexMinute:
      type: object
      description: "`value_f` is `official_f` when `phase` is `official`, else `provisional_f`."
      properties:
        minute: {type: string, format: date-time}
        phase: {type: string, enum: [provisional, official, missing]}
        final: {type: boolean}
        revision: {type: integer}
        value_f: {type: [number, "null"]}
        official_f: {type: [number, "null"]}
        recomputed_f: {type: [number, "null"]}
        provisional_f: {type: [number, "null"]}
        kalshi_status: {type: [string, "null"], enum: [normal, degraded, incomplete, null]}
        config_version: {type: string}
        contributors: {type: integer}
        weight_share: {type: number}
        quorum_met: {type: boolean}
        restated:
          type: boolean
          description: >-
            Kalshi changed the value after it was official; `official_f`/`official_at` keep the
            first official value and `restated_f`/`restated_at` hold the latest restatement.
        restated_f: {type: [number, "null"]}
        restated_at: {type: [string, "null"], format: date-time}
        receipt_basis: {type: [string, "null"]}
        calibration_pending: {type: boolean}
        provisional_at: {type: [string, "null"], format: date-time}
        official_at: {type: [string, "null"], format: date-time}
        stations:
          type: array
          items:
            $ref: "#/components/schemas/HourlyIndexStationReading"

    HourlyIndexHour:
      type: object
      properties:
        hour_end: {type: string, format: date-time}
        status: {type: string, enum: [open, pending]}
        event_ticker: {type: [string, "null"]}
        settles_now_as:
          type: [object, "null"]
          properties:
            minute: {type: string, format: date-time}
            value_f: {type: number}
            implied_floor_strike: {type: [number, "null"], description: "The open event's highest floor strike strictly below value_f"}
            nearest_strike_f: {type: [number, "null"], description: "The open event's floor strike nearest value_f"}
            distance_to_strike_f: {type: [number, "null"], description: "value_f minus nearest_strike_f (°F, signed)"}
            strike_side: {type: [string, "null"], enum: [above, below, null]}
        high_f: {type: [number, "null"]}
        low_f: {type: [number, "null"]}
        forecast_settle_f: {type: [number, "null"], description: HRRR index forecast at hour_end.}
        forecast_bias_f: {type: [number, "null"], description: Newest valued index minute minus the forecast at that minute.}
        forecast_adjusted_settle_f: {type: [number, "null"], description: forecast_settle_f + forecast_bias_f.}
        forecast_adjusted_floor_strike: {type: [number, "null"], description: N.99 strike the adjusted settle implies.}
        forecast_adjusted_nearest_strike_f: {type: [number, "null"], description: "The open event's floor strike nearest the adjusted settle"}
        forecast_adjusted_distance_to_strike_f: {type: [number, "null"], description: "forecast_adjusted_settle_f minus that strike (°F, signed)"}
        forecast_adjusted_strike_side: {type: [string, "null"], enum: [above, below, null]}
        final_at: {type: string, format: date-time}
        determine_by: {type: string, format: date-time}
        feed_conditions:
          type: array
          description: >-
            Degradations of the city's index feed over the final minutes of the 30 minutes ending at
            the newest final minute; empty when clean. Same shape as the REST HourlyIndexFeedCondition.
          items:
            type: object
            properties:
              type: {type: string, enum: [persistent_fallback, intermittent_fallback, member_excluded, qc_substitution, quorum_risk, unavailable_minutes]}
              severity: {type: string, enum: [warning, critical]}
              station_id: {type: [string, "null"]}
              minutes: {type: integer}
              window_minutes: {type: integer}
              share: {type: number}
              since: {type: string, format: date-time}
              last_seen: {type: string, format: date-time}
              codes: {type: array, items: {type: string}}
              contributors: {type: integer}
              members: {type: integer}
              weight_share: {type: number}

    HourlyIndexSettlement:
      type: object
      properties:
        hour_end: {type: string, format: date-time}
        status:
          type: string
          enum: [determined, no_value, incomplete_data]
          description: >-
            `incomplete_data`: our minutes have a gap after the last official minute of the window
            (`missing_minutes`), so the hour is never settled on an earlier minute. A market city's
            takes Kalshi's final value in `hour_reconciled` (`settle_source: kalshi_expiration`,
            `match` null); an index-only city's may become `determined`/`no_value` in a later
            `hour_settled` after the daily audit's one refetch.
        event_ticker: {type: [string, "null"]}
        settle_minute: {type: [string, "null"], format: date-time}
        settle_value_f: {type: [number, "null"]}
        hour_high_f: {type: [number, "null"]}
        hour_low_f: {type: [number, "null"]}
        winning_floor_strike: {type: [number, "null"]}
        informational: {type: boolean}
        determined_at: {type: string, format: date-time}
        kalshi_expiration_value: {type: [number, "null"]}
        kalshi_finalized_at: {type: [string, "null"], format: date-time}
        match: {type: [boolean, "null"]}
        settle_source: {type: string, enum: [computed, kalshi_expiration]}
        missing_minutes: {type: [array, "null"], items: {type: string, format: date-time}}
        revision: {type: integer}

    HourlyIndexCalibration:
      type: object
      properties:
        config_version: {type: string}
        effective_at: {type: string, format: date-time}
        published_at: {type: string, format: date-time}
        window_start: {type: [string, "null"], format: date-time}
        window_end: {type: [string, "null"], format: date-time}
        change_reason: {type: string}
        city_reference_c: {type: number}
        stations:
          type: array
          items:
            type: object

    HourlyIndexEvent:
      type: object
      properties:
        event_ticker: {type: string}
        series_ticker: {type: string}
        status: {type: string, enum: [listed, open, closed]}
        strike_time: {type: string, format: date-time}
        open_time: {type: [string, "null"], format: date-time}
        close_time: {type: [string, "null"], format: date-time}
        ladder:
          type: array
          items:
            type: object
            properties:
              ticker: {type: string}
              status: {type: string}
              strike_type: {type: string}
              floor_strike: {type: [number, "null"]}
              cap_strike: {type: [number, "null"]}
              yes_sub_title: {type: string}
              result: {type: string}

    HourlyIndexMarketQuote:
      type: object
      description: |
        One strike of the hourly ladder. Its quote fields are deprecated and always null on the
        API channels (since v1.29 they carry no market prices).
      properties:
        ticker: {type: string}
        event_ticker: {type: string}
        floor_strike: {type: [number, "null"]}
        cap_strike: {type: [number, "null"]}
        yes_bid: {type: [integer, "null"], deprecated: true}
        yes_ask: {type: [integer, "null"], deprecated: true}
        no_bid: {type: [integer, "null"], deprecated: true}
        no_ask: {type: [integer, "null"], deprecated: true}
        last_price: {type: [integer, "null"], deprecated: true}
        volume: {type: [number, "null"], deprecated: true}

    HourlyIndexForecast:
      type: object
      description: |
        Index forecast from the members' latest 15-minute runs of one forecast model (`model_id`:
        HRRR, or RRFS inside a `forecasts` map), computed with Kalshi's
        formula, the calibration effective at each step and the city's quorum. Arrays
        `value_f`, `contributors`, `weight_share`, `quorum_met`, `partial` and `members[].temp_f`
        are aligned to `steps`; the first step is the 15-minute step at or before the refresh.
      properties:
        model_id: {type: string, example: ncep_hrrr_conus_15min}
        run_time: {type: string, format: date-time, description: First step of the newest member run (its fetch step)}
        fetched_at: {type: string, format: date-time}
        config_version: {type: string}
        members_total: {type: integer}
        missing_members: {type: array, items: {type: string}}
        steps: {type: array, items: {type: string, format: date-time}}
        value_f: {type: array, items: {type: [number, "null"]}, description: Null below quorum}
        contributors: {type: array, items: {type: integer}}
        weight_share: {type: array, items: {type: number}}
        quorum_met: {type: array, items: {type: boolean}}
        partial: {type: array, items: {type: boolean}, description: Some members had no forecast}
        members:
          type: array
          items:
            type: object
            properties:
              station_id: {type: string}
              weight: {type: number}
              offset_c: {type: number}
              temp_f: {type: array, items: {type: [number, "null"]}}
        settles:
          type: array
          description: The next (up to 6) top-of-hour steps after the refresh.
          items:
            type: object
            properties:
              hour_end: {type: string, format: date-time}
              value_f: {type: [number, "null"]}
              implied_floor_strike: {type: [number, "null"], description: "Highest N.99 floor strike strictly below value_f"}
              quorum_met: {type: boolean}
              partial: {type: boolean}
              adjusted_value_f: {type: [number, "null"], description: "value_f + the forecast's bias_f"}
              adjusted_floor_strike: {type: [number, "null"], description: "Highest N.99 floor strike strictly below adjusted_value_f"}
        bias_f:
          type: [number, "null"]
          description: Newest valued index minute minus the forecast at that minute (see `forecast_bias`).
        bias_as_of_minute: {type: [string, "null"], format: date-time}
        adjusted_f:
          type: array
          description: "value_f[i] + bias_f, aligned to steps; null where either is null."
          items: {type: [number, "null"]}
        member_bias:
          type: [object, "null"]
          description: Per member, its reading at bias_as_of_minute minus its forecast temperature there (°F).
          additionalProperties: {type: [number, "null"]}

    HourlyIndexCity:
      type: object
      properties:
        display_name: {type: string}
        page_slug: {type: string}
        timezone: {type: string}
        config_version: {type: string}
        calibration_pending: {type: boolean}
        hourly_series_ticker: {type: [string, "null"]}
        forecast_station_id: {type: [string, "null"]}
        contract_terms_url: {type: [string, "null"], description: "Kalshi contract terms (PDF) of the city's hourly series; null for index-only cities."}
        quorum:
          type: object
          properties:
            min_members: {type: integer}
            min_weight: {type: number}
            published: {type: boolean}
        members:
          type: array
          items:
            type: object
            properties:
              station_id: {type: string}
              weight: {type: number}
              offset_c: {type: number}
        latest:
          oneOf:
            - $ref: "#/components/schemas/HourlyIndexMinute"
            - type: "null"
        latest_valued:
          description: Newest minute with a value (provisional or official).
          oneOf:
            - $ref: "#/components/schemas/HourlyIndexMinute"
            - type: "null"
        current_hour:
          oneOf:
            - $ref: "#/components/schemas/HourlyIndexHour"
            - type: "null"
        open_event:
          oneOf:
            - $ref: "#/components/schemas/HourlyIndexEvent"
            - type: "null"
        next_event:
          oneOf:
            - $ref: "#/components/schemas/HourlyIndexEvent"
            - type: "null"
        ladder:
          type: array
          items:
            $ref: "#/components/schemas/HourlyIndexMarketQuote"
        recent_settlements:
          type: array
          items:
            $ref: "#/components/schemas/HourlyIndexSettlement"
        forecast:
          description: The HRRR index forecast.
          oneOf:
            - $ref: "#/components/schemas/HourlyIndexForecast"
            - type: "null"
        forecasts:
          type: object
          description: |
            The other models' index forecasts, by model ID (today only `ncep_rrfs_conus_15min`).
            Absent while no other model has a forecast for the city; a forecast with no step left
            is not included.
          additionalProperties:
            $ref: "#/components/schemas/HourlyIndexForecast"

    OracleErrorDistribution:
      type: [object, "null"]
      description: |
        Same distribution as REST for all three modes. Signed error = forecast minus observed (°F).
        23 counters: below -10.5, 21 zero-centered 1°F bands, and at or above +10.5.
        Interior bins are lower-inclusive and upper-exclusive. Counts merge by addition across days;
        percentiles must be derived from merged counts, not averaged from daily percentiles.
        Each run contributes one sample per side; this is run-weighted, unlike day-weighted MAE/bias.
        Null means unavailable. day_count reports histogram-covered days; fewer than the enclosing
        score's day_count means partial coverage. Never substitute zero-error samples for legacy days.
      required: [version, bin_edges_f, high_counts, low_counts, sample_count, day_count]
      properties:
        version:
          type: string
          const: signed-error-f-v1
        bin_edges_f:
          type: array
          minItems: 22
          maxItems: 22
          description: Finite Fahrenheit boundaries -10.5, -9.5, ..., +10.5.
          items:
            type: number
        high_counts:
          type: array
          minItems: 23
          maxItems: 23
          items:
            type: integer
            minimum: 0
        low_counts:
          type: array
          minItems: 23
          maxItems: 23
          items:
            type: integer
            minimum: 0
        sample_count:
          type: integer
          minimum: 1
          description: Sum of either counter array (not both arrays combined).
        day_count:
          type: integer
          minimum: 1
          description: Number of days with histogram coverage.

    OracleScoreTablePayload:
      type: object
      description: Aggregated oracle scores for one mode (matches REST `data` for GET oracle-scores).
      properties:
        station_id:
          type: string
        range_start:
          type: string
        range_end:
          type: string
        days_requested:
          type: integer
        all_time:
          type: boolean
        score_mode:
          type: string
        rank_by:
          type: string
        scores:
          type: array
          items:
            type: object
            properties:
              model_id:
                type: string
              model_name:
                type: string
              is_public:
                type: boolean
              high_mae:
                type: number
              low_mae:
                type: number
              high_bias:
                type: number
              low_bias:
                type: number
              combined_mae:
                type: number
              day_count:
                type: integer
              error_distribution:
                $ref: "#/components/schemas/OracleErrorDistribution"
