> ## Documentation Index
> Fetch the complete documentation index at: https://docs.simplefunctions.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Market Watch panels

> User-configurable preset and screen panels on /dashboard2/market-watch — Upstash-cached, per-tier rate-limited dashboard surface.

Market Watch panels are user-configured surfaces that live above the fixed "Radar pulse" panes on `/dashboard2/market-watch`. Each panel is either a **preset** (one of eight pre-built fetchers) or a **screen** (filter expressed over a single read-only source).

<Note>
  These endpoints are **session-authenticated only** (Supabase cookie), not Bearer-API. They power the dashboard UI; external automation should use the public/agent APIs.
</Note>

The system is gated three ways:

1. **Per-route rate limit** — same `withRequestLog` wrapper as the rest of the dashboard. RPM + monthly hard cap per tier.
2. **Tier panel caps** — number of panels you can keep is `market_watch_panel_cap` from `tier_config`. Screen panels have a tighter cap (`market_watch_screen_panel_cap`).
3. **Per-panel refresh floor** — server clamps any `schedule.cadenceMinutes` to `market_watch_min_refresh_seconds` and a manual refresh has its own per-panel cooldown.

Default limits at launch:

| Tier          | Total panels | Screen panels | Min refresh (s) | Manual cooldown (s) |
| ------------- | -----------: | ------------: | --------------: | ------------------: |
| Free          |            3 |             1 |             300 |                 300 |
| Hobby         |           12 |             5 |              60 |                  60 |
| Pro           |           50 |            25 |              15 |                  15 |
| Institutional |          200 |           100 |               5 |                   5 |

## Hydration

```http theme={null}
GET /api/dashboard2/market-watch-v2
```

Returns the legacy fixed-pane payload, every active panel for the calling user (with its cached payload), and the current tier limits + counts. Response is `private, max-age=60, stale-while-revalidate=300`.

<Expandable title="Response shape">
  ```json theme={null}
  {
    "dashboard": {
      "id": "uuid",
      "name": "Default",
      "isDefault": true,
      "layout": {}
    },
    "panes": {
      "servedAt": "2026-05-22T18:00:00.000Z",
      "indexSeries": { "asOf": [], "disagreement": [], "breadth": [], "geoRisk": [], "activity": [], "current": null },
      "themes": [],
      "regimeBuckets": {},
      "contagion": [],
      "movers": [],
      "sources": { "index": {}, "liquidity": {}, "regime": {}, "contagion": {}, "movers": {} }
    },
    "panels": [
      {
        "id": "panel-uuid",
        "title": "SF Index · 24h",
        "kind": "preset",
        "spec": { "version": 1, "kind": "preset", "preset": { "id": "sf_index_24h" }, "display": { "visualization": "sparkline" }, "schedule": { "mode": "manual" } },
        "status": "active",
        "sortIdx": 0,
        "refreshFloorSeconds": 300,
        "lastRunAt": "2026-05-22T17:59:00.000Z",
        "lastError": null,
        "payload": { /* preset-specific */ },
        "sourceClock": {},
        "cacheState": "fresh",
        "executionStatus": "success-fresh"
      }
    ],
    "tier": {
      "panelCap": 3,
      "screenPanelCap": 1,
      "minRefreshSeconds": 300,
      "manualRefreshCooldownSeconds": 300,
      "currentPanelCount": 1,
      "currentScreenPanelCount": 0
    },
    "generatedAt": "2026-05-22T18:00:00.000Z"
  }
  ```
</Expandable>

Panel fan-out is bounded at `pMap(concurrency=4)` so even an institutional tier with 200 panels only runs 4 factory calls at a time. Cache hits never reach Postgres.

## Create a panel

```http theme={null}
POST /api/dashboard2/market-watch/panels
```

Body:

```json theme={null}
{
  "title": "SF Index 24h",
  "spec": {
    "version": 1,
    "kind": "preset",
    "title": "SF Index 24h",
    "preset": { "id": "sf_index_24h" },
    "display": { "visualization": "sparkline" },
    "schedule": { "mode": "manual" }
  }
}
```

Returns `201` with `{ "panel": { id, title, kind, status, sortIdx, refreshFloorSeconds } }`.

Failures use a stable `reason` enum:

| Reason                                                                                     | When                                                              |
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
| `spec_required`                                                                            | body has no `spec`                                                |
| `invalid_json_body`                                                                        | body is not parseable JSON                                        |
| `kind_deferred_to_future_spec`                                                             | `kind` is `agentic_query`, `alert_linked`, or `external_delivery` |
| `kind_unknown`                                                                             | `kind` is not `preset` or `screen`                                |
| `preset_missing` / `preset_unknown_id`                                                     | preset payload missing or id not in the registry                  |
| `screen_sources_missing` / `screen_sources_empty` / `screen_source_unknown`                | screen panel source list invalid                                  |
| `filters_probability_out_of_range`                                                         | `filters.probability.min/max` outside `[0, 1]`                    |
| `filters_window_invalid`                                                                   | `filters.window` not one of `1h / 6h / 24h / 7d / 30d`            |
| `filters_tickers_too_many`                                                                 | more than 25 entries                                              |
| `display_missing` / `display_visualization_invalid`                                        | display block missing or visualization unknown                    |
| `display_visualization_deferred_to_future_spec`                                            | reserved for agentic (`digest`)                                   |
| `schedule_mode_invalid` / `schedule_cadence_missing` / `schedule_cadence_below_tier_floor` | schedule shape or interval below tier floor                       |
| `market_watch_panel_cap_exceeded`                                                          | tier cap hit                                                      |
| `market_watch_screen_panel_cap_exceeded`                                                   | tier cap hit, screen-specific                                     |

## Presets (v1)

Pass `preset.id` from this list:

| Preset id                  | What it returns                                                           |
| -------------------------- | ------------------------------------------------------------------------- |
| `sf_index_24h`             | 4-line SF Index sparklines (disagreement / breadth / geoRisk / activity). |
| `liquidity_by_theme`       | Stacked 24h volume share across themes.                                   |
| `regime_attention`         | Markets bucketed by regime score in the latest 30m window.                |
| `cross_venue_contagion`    | 6h trigger→lagging signal rail across Kalshi/Polymarket.                  |
| `movers_volume_z`          | Top markets by 30d-baseline volume z-score.                               |
| `data_health`              | Per-preset freshness + degraded flag.                                     |
| `calendar_catalysts`       | Upcoming market expirations grouped by date.                              |
| `watchlist_microstructure` | Per-user pinned tickers with price / volume / freshness.                  |

## Screen sources

For `kind: "screen"`, pass `sources` as an array containing exactly one of:

```
latest_market_prices
market_regime_snapshots
sf_index_snapshots
liquidity_by_theme
contagion_bundles
watched_objects
alert_rules
public_query
public_context
```

v1 ships executor coverage for `latest_market_prices`; other sources validate but execute as empty.

## Update / delete / reorder

```http theme={null}
PATCH  /api/dashboard2/market-watch/panels/{id}
DELETE /api/dashboard2/market-watch/panels/{id}
POST   /api/dashboard2/market-watch/panels/reorder
```

PATCH body accepts any subset of `{ title, spec, status, sortIdx }`. Spec PATCH **replaces** the full spec — no JSON merge. Cache for the panel is invalidated after commit.

Reorder body: `{ "order": [{ "id": "...", "sortIdx": 0 }, ...] }`. All ids must be owned by the caller, else `404 panel_not_found_or_not_owned`.

## Manual refresh

```http theme={null}
POST /api/dashboard2/market-watch/panels/{id}/run
```

Synchronous: claims a per-user-per-panel cooldown via Upstash `SET NX EX`, runs the panel with `allowStale=false`, returns the fresh envelope.

```json theme={null}
{
  "panelId": "uuid",
  "status": "success-miss",
  "payload": { /* same shape as panels[].payload from hydration */ },
  "sourceClock": {},
  "generatedAt": "2026-05-22T18:00:01.000Z",
  "runId": "run_..."
}
```

Cooldown violations return `429` with `Retry-After`:

```http theme={null}
HTTP/1.1 429 Too Many Requests
Retry-After: 300
{ "reason": "panel_refresh_cooldown", "retryAfterSeconds": 300, "upgrade": { "url": "https://simplefunctions.dev/pricing" } }
```

## Tier-gate headers

Every route inherits the dashboard rate-limit headers from `withRequestLog`:

* `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` — minute window.
* On block: `429` with `Retry-After` and `X-SF-Block-Reason`.

## Errors and recovery

* **Redis outage** — `runPanelWithCache` falls through to `bypass` (factory runs inline, no cache write). Cooldown bypass is graceful too: the panel's `refresh_floor_seconds` floor still gates the cache window once Redis recovers.
* **Validator failure** — never persists. The `reason` enum is stable and safe to surface.
* **Factory error** — recorded in `market_watch_panel_runs.error` plus `market_watch_panels.last_error`. Replays still work via the next refresh.

## Admin observability

```http theme={null}
GET /api/admin/market-watch/health
```

Admin-only (via `ADMIN_EMAILS`). Returns four aggregates:

```json theme={null}
{
  "panels_by_status_kind": [{ "status": "active", "kind": "preset", "panels": 7 }],
  "presets_active":        [{ "presetId": "sf_index_24h", "panels": 3 }],
  "runs_last_24h":         [{ "status": "success", "runs": 120, "errors": 0, "avgDurationMs": 18.5 }],
  "recent_errors":         [{ "id": "...", "title": "...", "kind": "...", "lastError": "...", "lastRunAt": "..." }],
  "generatedAt": "2026-05-22T18:00:00.000Z"
}
```

No fan-out beyond these four bounded queries.
