> ## 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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.